mirror of
https://github.com/luxfi/netrunner.git
synced 2026-07-27 00:04:23 +00:00
260 lines
6.8 KiB
Go
260 lines
6.8 KiB
Go
// Copyright (C) 2019-2022, Ava Labs, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package client implements client.
|
|
package client
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ava-labs/avalanche-network-runner/pkg/color"
|
|
"github.com/ava-labs/avalanche-network-runner/pkg/logutil"
|
|
"github.com/ava-labs/avalanche-network-runner/rpcpb"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type Config struct {
|
|
LogLevel string
|
|
Endpoint string
|
|
DialTimeout time.Duration
|
|
}
|
|
|
|
type Client interface {
|
|
Ping(ctx context.Context) (*rpcpb.PingResponse, error)
|
|
Start(ctx context.Context, execPath string, opts ...OpOption) (*rpcpb.StartResponse, error)
|
|
Health(ctx context.Context) (*rpcpb.HealthResponse, error)
|
|
URIs(ctx context.Context) ([]string, error)
|
|
Status(ctx context.Context) (*rpcpb.StatusResponse, error)
|
|
StreamStatus(ctx context.Context, pushInterval time.Duration) (<-chan *rpcpb.ClusterInfo, error)
|
|
RemoveNode(ctx context.Context, name string) (*rpcpb.RemoveNodeResponse, error)
|
|
RestartNode(ctx context.Context, name string, execPath string, opts ...OpOption) (*rpcpb.RestartNodeResponse, error)
|
|
Stop(ctx context.Context) (*rpcpb.StopResponse, error)
|
|
Close() error
|
|
}
|
|
|
|
type client struct {
|
|
cfg Config
|
|
|
|
conn *grpc.ClientConn
|
|
|
|
pingc rpcpb.PingServiceClient
|
|
controlc rpcpb.ControlServiceClient
|
|
|
|
closed chan struct{}
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func New(cfg Config) (Client, error) {
|
|
lcfg := logutil.GetDefaultZapLoggerConfig()
|
|
lcfg.Level = zap.NewAtomicLevelAt(logutil.ConvertToZapLevel(cfg.LogLevel))
|
|
logger, err := lcfg.Build()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = zap.ReplaceGlobals(logger)
|
|
|
|
color.Outf("{{blue}}dialing endpoint %q{{/}}\n", cfg.Endpoint)
|
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.DialTimeout)
|
|
conn, err := grpc.DialContext(
|
|
ctx,
|
|
cfg.Endpoint,
|
|
grpc.WithBlock(),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &client{
|
|
cfg: cfg,
|
|
conn: conn,
|
|
pingc: rpcpb.NewPingServiceClient(conn),
|
|
controlc: rpcpb.NewControlServiceClient(conn),
|
|
closed: make(chan struct{}),
|
|
}, nil
|
|
}
|
|
|
|
func (c *client) Ping(ctx context.Context) (*rpcpb.PingResponse, error) {
|
|
zap.L().Info("ping")
|
|
|
|
// ref. https://grpc-ecosystem.github.io/grpc-gateway/docs/tutorials/adding_annotations/
|
|
// curl -X POST -k http://localhost:8081/v1/ping -d ''
|
|
return c.pingc.Ping(ctx, &rpcpb.PingRequest{})
|
|
}
|
|
|
|
func (c *client) Start(ctx context.Context, execPath string, opts ...OpOption) (*rpcpb.StartResponse, error) {
|
|
ret := &Op{}
|
|
ret.applyOpts(opts)
|
|
|
|
zap.L().Info("start")
|
|
return c.controlc.Start(ctx, &rpcpb.StartRequest{
|
|
ExecPath: execPath,
|
|
WhitelistedSubnets: &ret.whitelistedSubnets,
|
|
LogLevel: &ret.logLevel,
|
|
})
|
|
}
|
|
|
|
func (c *client) Health(ctx context.Context) (*rpcpb.HealthResponse, error) {
|
|
zap.L().Info("health")
|
|
return c.controlc.Health(ctx, &rpcpb.HealthRequest{})
|
|
}
|
|
|
|
func (c *client) URIs(ctx context.Context) ([]string, error) {
|
|
zap.L().Info("uris")
|
|
resp, err := c.controlc.URIs(ctx, &rpcpb.URIsRequest{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Uris, nil
|
|
}
|
|
|
|
func (c *client) Status(ctx context.Context) (*rpcpb.StatusResponse, error) {
|
|
zap.L().Info("status")
|
|
return c.controlc.Status(ctx, &rpcpb.StatusRequest{})
|
|
}
|
|
|
|
func (c *client) StreamStatus(ctx context.Context, pushInterval time.Duration) (<-chan *rpcpb.ClusterInfo, error) {
|
|
stream, err := c.controlc.StreamStatus(ctx, &rpcpb.StreamStatusRequest{
|
|
PushInterval: int64(pushInterval),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ch := make(chan *rpcpb.ClusterInfo, 1)
|
|
go func() {
|
|
defer func() {
|
|
zap.L().Debug("closing stream send", zap.Error(stream.CloseSend()))
|
|
close(ch)
|
|
}()
|
|
zap.L().Info("start receive routine")
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-c.closed:
|
|
return
|
|
default:
|
|
}
|
|
|
|
// receive data from stream
|
|
msg := new(rpcpb.StatusResponse)
|
|
err := stream.RecvMsg(msg)
|
|
if err == nil {
|
|
ch <- msg.GetClusterInfo()
|
|
continue
|
|
}
|
|
|
|
if errors.Is(err, io.EOF) {
|
|
zap.L().Debug("received EOF from client; returning to close the stream from server side")
|
|
return
|
|
}
|
|
if isClientCanceled(stream.Context().Err(), err) {
|
|
zap.L().Warn("failed to receive status request from gRPC stream due to client cancellation", zap.Error(err))
|
|
} else {
|
|
zap.L().Warn("failed to receive status request from gRPC stream", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
}()
|
|
return ch, nil
|
|
}
|
|
|
|
func (c *client) Stop(ctx context.Context) (*rpcpb.StopResponse, error) {
|
|
zap.L().Info("stop")
|
|
return c.controlc.Stop(ctx, &rpcpb.StopRequest{})
|
|
}
|
|
|
|
func (c *client) RemoveNode(ctx context.Context, name string) (*rpcpb.RemoveNodeResponse, error) {
|
|
zap.L().Info("remove node", zap.String("name", name))
|
|
return c.controlc.RemoveNode(ctx, &rpcpb.RemoveNodeRequest{Name: name})
|
|
}
|
|
|
|
func (c *client) RestartNode(ctx context.Context, name string, execPath string, opts ...OpOption) (*rpcpb.RestartNodeResponse, error) {
|
|
ret := &Op{}
|
|
ret.applyOpts(opts)
|
|
|
|
zap.L().Info("restart node", zap.String("name", name))
|
|
return c.controlc.RestartNode(ctx, &rpcpb.RestartNodeRequest{
|
|
Name: name,
|
|
StartRequest: &rpcpb.StartRequest{
|
|
ExecPath: execPath,
|
|
WhitelistedSubnets: &ret.whitelistedSubnets,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (c *client) Close() error {
|
|
c.closeOnce.Do(func() {
|
|
close(c.closed)
|
|
})
|
|
return c.conn.Close()
|
|
}
|
|
|
|
type Op struct {
|
|
whitelistedSubnets string
|
|
logLevel string
|
|
}
|
|
|
|
type OpOption func(*Op)
|
|
|
|
func (op *Op) applyOpts(opts []OpOption) {
|
|
for _, opt := range opts {
|
|
opt(op)
|
|
}
|
|
}
|
|
|
|
func WithWhitelistedSubnets(whitelistedSubnets string) OpOption {
|
|
return func(op *Op) {
|
|
op.whitelistedSubnets = whitelistedSubnets
|
|
}
|
|
}
|
|
|
|
func WithLogLevel(logLevel string) OpOption {
|
|
return func(op *Op) {
|
|
op.logLevel = logLevel
|
|
}
|
|
}
|
|
|
|
func isClientCanceled(ctxErr error, err error) bool {
|
|
if ctxErr != nil {
|
|
return true
|
|
}
|
|
|
|
ev, ok := status.FromError(err)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
switch ev.Code() {
|
|
case codes.Canceled, codes.DeadlineExceeded:
|
|
// client-side context cancel or deadline exceeded
|
|
// "rpc error: code = Canceled desc = context canceled"
|
|
// "rpc error: code = DeadlineExceeded desc = context deadline exceeded"
|
|
return true
|
|
case codes.Unavailable:
|
|
msg := ev.Message()
|
|
// client-side context cancel or deadline exceeded with TLS ("http2.errClientDisconnected")
|
|
// "rpc error: code = Unavailable desc = client disconnected"
|
|
if msg == "client disconnected" {
|
|
return true
|
|
}
|
|
// "grpc/transport.ClientTransport.CloseStream" on canceled streams
|
|
// "rpc error: code = Unavailable desc = stream error: stream ID 21; CANCEL")
|
|
if strings.HasPrefix(msg, "stream error: ") && strings.HasSuffix(msg, "; CANCEL") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|