diff --git a/cli/aeon/client.go b/cli/aeon/client.go new file mode 100644 index 000000000..ed296d0f5 --- /dev/null +++ b/cli/aeon/client.go @@ -0,0 +1,211 @@ +package aeon + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + "time" + + "github.com/apex/log" + + "github.com/tarantool/go-prompt" + "github.com/tarantool/tt/cli/aeon/cmd" + "github.com/tarantool/tt/cli/aeon/pb" + "github.com/tarantool/tt/cli/connector" + "github.com/tarantool/tt/cli/console" + "github.com/tarantool/tt/cli/formatter" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +type ResultType struct { + data map[string][]any + count int +} + +type Client struct { + title string + conn *grpc.ClientConn + client pb.AeonRouterServiceClient +} + +func makeAddress(ctx cmd.ConnectCtx) string { + if ctx.Network == connector.UnixNetwork { + if strings.HasPrefix(ctx.Address, "@") { + return "unix-abstract:" + (ctx.Address)[1:] + } + return "unix:" + ctx.Address + } + return ctx.Address +} + +func getCertificate(args cmd.Ssl) []tls.Certificate { + if args.CertFile == "" && args.KeyFile == "" { + return []tls.Certificate{} + } + tls_cert, err := tls.LoadX509KeyPair(args.CertFile, args.KeyFile) + if err != nil { + log.Fatalf("Could not load client key pair: %v", err) + } + return []tls.Certificate{tls_cert} +} + +func getTlsConfig(args cmd.Ssl) *tls.Config { + if args.CaFile == "" { + return &tls.Config{ + ClientAuth: tls.NoClientCert, + } + } + + ca, err := os.ReadFile(args.CaFile) + if err != nil { + log.Fatalf("Failed to read CA file: %v", err) + } + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(ca) { + log.Fatal("Failed to append CA data") + } + return &tls.Config{ + Certificates: getCertificate(args), + ClientAuth: tls.RequireAndVerifyClientCert, + RootCAs: certPool, + } +} + +func getDialOpts(ctx cmd.ConnectCtx) grpc.DialOption { + var creds credentials.TransportCredentials + if ctx.Transport == cmd.TransportSsl { + creds = credentials.NewTLS(getTlsConfig(ctx.Ssl)) + } else { + creds = insecure.NewCredentials() + } + return grpc.WithTransportCredentials(creds) +} + +// NewAeonHandler create new grpc connection to Aeon server. +func NewAeonHandler(ctx cmd.ConnectCtx) *Client { + c := Client{title: ctx.Address} + target := makeAddress(ctx) + var err error + c.conn, err = grpc.NewClient(target, getDialOpts(ctx)) + if err != nil { + log.Fatalf("Fail to dial: %v", err) + } + c.client = pb.NewAeonRouterServiceClient(c.conn) + + if c.ping() { + log.Infof("Aeon responses at %q", target) + } else { + log.Fatalf("Can't ping to Aeon at %q", target) + } + return &c +} + +func (c *Client) ping() bool { + log.Infof("Start ping aeon server") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := c.client.Ping(ctx, &pb.PingRequest{}) + return err == nil +} + +// Title implements console.Handler interface. +func (c *Client) Title() string { + return c.title +} + +// Validate implements console.Handler interface. +func (c *Client) Validate(input string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + check, err := c.client.SQLCheck(ctx, &pb.SQLRequest{Query: input}) + if err != nil { + return false + } + + return check.Status == pb.SQLCheckStatus_SQL_QUERY_VALID +} + +// Execute implements console.Handler interface. +func (c *Client) Execute(input string) any { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := c.client.SQL(ctx, &pb.SQLRequest{Query: input}) + if err != nil { + return err + } + return parseSQLResponse(resp) + +} + +// Stop implements console.Handler interface. +func (c *Client) Close() { + c.conn.Close() +} + +// Complete implements console.Handler interface. +func (c *Client) Complete(input prompt.Document) []prompt.Suggest { + // TODO: waiting until there is support from Aeon side. + return nil +} + +// parseSQLResponse returns result as table in map. +// Where keys is name of columns. And body is array of values. +// On any issue return an error. +func parseSQLResponse(resp *pb.SQLResponse) any { + if resp.Error != nil { + return fmt.Errorf("something wrong with SQL request: %s", resp.Error) + } + res := ResultType{ + data: make(map[string][]any, len(resp.TupleFormat.Names)), + count: len(resp.Tuples), + } + // result := make(ResultType, len(resp.TupleFormat.Names)) + rows := len(resp.Tuples) + for _, f := range resp.TupleFormat.Names { + res.data[f] = make([]any, 0, rows) + } + + for r, row := range resp.Tuples { + for i, v := range row.Fields { + k := resp.TupleFormat.Names[i] + val, err := decodeValue(v) + if err != nil { + return fmt.Errorf("tuple %d can't decode %s: %w", r, v.String(), err) + } + res.data[k] = append(res.data[k], val) + } + } + return res +} + +// asYaml prepare results for formatter.MakeOutput. +func (r ResultType) asYaml() string { + yaml := "---\n" + for i := range r.count { + mark := "-" + for k, v := range r.data { + if i < len(v) { + yaml += fmt.Sprintf("%s %s: %v\n", mark, k, v[i]) + mark = " " + } + } + } + return yaml +} + +// Format produce formatted string according required console.Format settings. +func (r ResultType) Format(f console.Format) string { + output, err := formatter.MakeOutput(f.Mode, r.asYaml(), f.Opts) + if err != nil { + return fmt.Sprintf("can't format output: %s;\nResults:\n%v", err, r) + } + return output +} diff --git a/cli/aeon/cmd/connect.go b/cli/aeon/cmd/connect.go index cdb3b80a2..8940441d8 100644 --- a/cli/aeon/cmd/connect.go +++ b/cli/aeon/cmd/connect.go @@ -16,4 +16,8 @@ type ConnectCtx struct { Ssl Ssl // Transport is a connection mode. Transport Transport + // Network is kind of transport layer. + Network string + // Address is a connection Url, unix socket address and etc. + Address string } diff --git a/cli/aeon/decode.go b/cli/aeon/decode.go new file mode 100644 index 000000000..3e949e762 --- /dev/null +++ b/cli/aeon/decode.go @@ -0,0 +1,102 @@ +package aeon + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "github.com/tarantool/go-tarantool/v2/datetime" + "github.com/tarantool/go-tarantool/v2/decimal" + "github.com/tarantool/tt/cli/aeon/pb" +) + +/* +decodeValue convert a value obtained from protobuf into a value that can be used as an +argument to Tarantool functions. + +Copy from https://github.com/tarantool/aeon/blob/master/aeon/grpc/server/pb/decode.go +*/ +func decodeValue(val *pb.Value) (any, error) { + switch casted := val.Kind.(type) { + case *pb.Value_UnsignedValue: + return val.GetUnsignedValue(), nil + case *pb.Value_StringValue: + return val.GetStringValue(), nil + case *pb.Value_NumberValue: + return val.GetNumberValue(), nil + case *pb.Value_IntegerValue: + return val.GetIntegerValue(), nil + case *pb.Value_BooleanValue: + return val.GetBooleanValue(), nil + case *pb.Value_VarbinaryValue: + return val.GetVarbinaryValue(), nil + case *pb.Value_DecimalValue: + decStr := val.GetDecimalValue() + res, err := decimal.MakeDecimalFromString(decStr) + if err != nil { + return nil, err + } + return res, nil + case *pb.Value_UuidValue: + uuidStr := val.GetUuidValue() + res, err := uuid.Parse(uuidStr) + if err != nil { + return nil, err + } + return res, nil + case *pb.Value_DatetimeValue: + sec := casted.DatetimeValue.Seconds + nsec := casted.DatetimeValue.Nsec + t := time.Unix(sec, nsec) + if len(casted.DatetimeValue.Location) > 0 { + locStr := casted.DatetimeValue.Location + loc, err := time.LoadLocation(locStr) + if err != nil { + return nil, err + } + t = t.In(loc) + } + res, err := datetime.MakeDatetime(t) + if err != nil { + return nil, err + } + return res, nil + case *pb.Value_IntervalValue: + res := datetime.Interval{ + Year: casted.IntervalValue.Year, + Month: casted.IntervalValue.Month, + Week: casted.IntervalValue.Week, + Day: casted.IntervalValue.Day, + Hour: casted.IntervalValue.Hour, + Min: casted.IntervalValue.Min, + Sec: casted.IntervalValue.Sec, + Nsec: casted.IntervalValue.Nsec, + Adjust: datetime.Adjust(casted.IntervalValue.Adjust)} + return res, nil + case *pb.Value_ArrayValue: + array := val.GetArrayValue() + res := make([]any, len(array.Fields)) + for k, v := range array.Fields { + field, err := decodeValue(v) + if err != nil { + return nil, err + } + res[k] = field + } + return res, nil + case *pb.Value_MapValue: + res := make(map[any]any, len(casted.MapValue.Fields)) + for k, v := range casted.MapValue.Fields { + item, err := decodeValue(v) + if err != nil { + return nil, err + } + res[k] = item + } + return res, nil + case *pb.Value_NullValue: + return nil, nil + default: + return nil, fmt.Errorf("unsupported type for value") + } +} diff --git a/cli/cmd/aeon.go b/cli/cmd/aeon.go index 0e41f0d4d..dba5f7009 100644 --- a/cli/cmd/aeon.go +++ b/cli/cmd/aeon.go @@ -5,15 +5,17 @@ import ( "fmt" "github.com/spf13/cobra" - aeon "github.com/tarantool/tt/cli/aeon/cmd" + aeon "github.com/tarantool/tt/cli/aeon" + aeon_cmd "github.com/tarantool/tt/cli/aeon/cmd" "github.com/tarantool/tt/cli/cmdcontext" + "github.com/tarantool/tt/cli/console" "github.com/tarantool/tt/cli/modules" "github.com/tarantool/tt/cli/util" libconnect "github.com/tarantool/tt/lib/connect" ) -var aeonConnectCtx = aeon.ConnectCtx{ - Transport: aeon.TransportPlain, +var connectCtx = aeon_cmd.ConnectCtx{ + Transport: aeon_cmd.TransportPlain, } func newAeonConnectCmd() *cobra.Command { @@ -35,15 +37,15 @@ func newAeonConnectCmd() *cobra.Command { util.HandleCmdErr(cmd, err) }, } - aeonCmd.Flags().StringVar(&aeonConnectCtx.Ssl.KeyFile, "sslkeyfile", "", + aeonCmd.Flags().StringVar(&connectCtx.Ssl.KeyFile, "sslkeyfile", "", "path to a private SSL key file") - aeonCmd.Flags().StringVar(&aeonConnectCtx.Ssl.CertFile, "sslcertfile", "", + aeonCmd.Flags().StringVar(&connectCtx.Ssl.CertFile, "sslcertfile", "", "path to a SSL certificate file") - aeonCmd.Flags().StringVar(&aeonConnectCtx.Ssl.CaFile, "sslcafile", "", + aeonCmd.Flags().StringVar(&connectCtx.Ssl.CaFile, "sslcafile", "", "path to a trusted certificate authorities (CA) file") - aeonCmd.Flags().Var(&aeonConnectCtx.Transport, "transport", - fmt.Sprintf("allowed %s", aeon.ListValidTransports())) + aeonCmd.Flags().Var(&connectCtx.Transport, "transport", + fmt.Sprintf("allowed %s", aeon_cmd.ListValidTransports())) aeonCmd.RegisterFlagCompletionFunc("transport", aeonTransportCompletion) return aeonCmd @@ -51,8 +53,8 @@ func newAeonConnectCmd() *cobra.Command { func aeonTransportCompletion(cmd *cobra.Command, args []string, toComplete string) ( []string, cobra.ShellCompDirective) { - suggest := make([]string, 0, len(aeon.ValidTransport)) - for k, v := range aeon.ValidTransport { + suggest := make([]string, 0, len(aeon_cmd.ValidTransport)) + for k, v := range aeon_cmd.ValidTransport { suggest = append(suggest, string(k)+"\t"+v) } return suggest, cobra.ShellCompDirectiveDefault @@ -71,36 +73,50 @@ func NewAeonCmd() *cobra.Command { } func aeonConnectValidateArgs(cmd *cobra.Command, args []string) error { - if !cmd.Flags().Changed("transport") && (aeonConnectCtx.Ssl.KeyFile != "" || - aeonConnectCtx.Ssl.CertFile != "" || aeonConnectCtx.Ssl.CaFile != "") { - aeonConnectCtx.Transport = aeon.TransportSsl + connectCtx.Network, connectCtx.Address = libconnect.ParseBaseURI(args[0]) + + if !cmd.Flags().Changed("transport") && (connectCtx.Ssl.KeyFile != "" || + connectCtx.Ssl.CertFile != "" || connectCtx.Ssl.CaFile != "") { + connectCtx.Transport = aeon_cmd.TransportSsl } checkFile := func(path string) bool { return path == "" || util.IsRegularFile(path) } - if aeonConnectCtx.Transport != aeon.TransportPlain { + if connectCtx.Transport != aeon_cmd.TransportPlain { if cmd.Flags().Changed("sslkeyfile") != cmd.Flags().Changed("sslcertfile") { return errors.New("files Key and Cert must be specified both") } - if !checkFile(aeonConnectCtx.Ssl.KeyFile) { + if !checkFile(connectCtx.Ssl.KeyFile) { return fmt.Errorf("not valid path to a private SSL key file=%q", - aeonConnectCtx.Ssl.KeyFile) + connectCtx.Ssl.KeyFile) } - if !checkFile(aeonConnectCtx.Ssl.CertFile) { + if !checkFile(connectCtx.Ssl.CertFile) { return fmt.Errorf("not valid path to an SSL certificate file=%q", - aeonConnectCtx.Ssl.CertFile) + connectCtx.Ssl.CertFile) } - if !checkFile(aeonConnectCtx.Ssl.CaFile) { + if !checkFile(connectCtx.Ssl.CaFile) { return fmt.Errorf("not valid path to trusted certificate authorities (CA) file=%q", - aeonConnectCtx.Ssl.CaFile) + connectCtx.Ssl.CaFile) } } return nil } func internalAeonConnect(cmdCtx *cmdcontext.CmdCtx, args []string) error { + opts := console.ConsoleOpts{ + Handler: aeon.NewAeonHandler(connectCtx), + Format: console.DefaultConsoleFormat(), + } + c, err := console.NewConsole(opts) + if err != nil { + return fmt.Errorf("can't create aeon console: %w", err) + } + err = c.Run() + if err != nil { + return fmt.Errorf("can't start aeon console: %w", err) + } return nil } diff --git a/cli/console/console.go b/cli/console/console.go index be59a9d88..d9b3750f2 100644 --- a/cli/console/console.go +++ b/cli/console/console.go @@ -96,9 +96,9 @@ func (c *Console) Run() error { // Close frees up resources used by the console. func (c *Console) Close() { - c.impl.Handler.Stop() + c.impl.Handler.Close() if c.impl.History != nil { - c.impl.History.Stop() + c.impl.History.Close() } } diff --git a/cli/console/format.go b/cli/console/format.go deleted file mode 100644 index 5579d6b09..000000000 --- a/cli/console/format.go +++ /dev/null @@ -1,26 +0,0 @@ -package console - -import "github.com/tarantool/tt/cli/formatter" - -type Format struct { - // Mode specify how to formatting result. - Mode formatter.Format - // Opts options for Format. - Opts formatter.Opts -} - -func (f Format) print(HandlerResult) error { - // TODO: implement formatting and print results. - return nil -} - -func DefaultConsoleFormat() Format { - return Format{ - Mode: formatter.TableFormat, - Opts: formatter.Opts{ - Graphics: true, - ColumnWidthMax: 0, - TableDialect: formatter.DefaultTableDialect, - }, - } -} diff --git a/cli/console/formatter.go b/cli/console/formatter.go new file mode 100644 index 000000000..ce306fb2e --- /dev/null +++ b/cli/console/formatter.go @@ -0,0 +1,45 @@ +package console + +import ( + "fmt" + + "github.com/tarantool/tt/cli/formatter" +) + +type Format struct { + // Mode specify how to formatting result. + Mode formatter.Format + // Opts options for Format. + Opts formatter.Opts +} + +// Formatter interface provide common interface for console Handlers to format execution results. +type Formatter interface { + // Format result data according fmt settings and return string for printing. + Format(fmt Format) string +} + +func (f Format) print(data any) error { + // First ensure that data object implemented Formatter interface. + if f_obj, ok := data.(Formatter); ok { + fmt.Println(f_obj.Format(f)) + return nil + } + // Then checking is it has String method. + if s_obj, ok := data.(fmt.Stringer); ok { + fmt.Println(s_obj.String()) + return nil + } + return fmt.Errorf("can't format type=%T", data) +} + +func DefaultConsoleFormat() Format { + return Format{ + Mode: formatter.TableFormat, + Opts: formatter.Opts{ + Graphics: true, + ColumnWidthMax: 0, + TableDialect: formatter.DefaultTableDialect, + }, + } +} diff --git a/cli/console/handler.go b/cli/console/handler.go index 0ee3cdaa9..3d7707711 100644 --- a/cli/console/handler.go +++ b/cli/console/handler.go @@ -2,12 +2,6 @@ package console import "github.com/tarantool/go-prompt" -// HandlerResult structure of data records. -// Map keys is names of columns. And map value is content of column. -// TODO: Solve what need return to make easy apply Formatter. -// Possible: can we make it return an interface with methods to be handled in Formatter? -type HandlerResult map[string]any - // Handler is a auxiliary abstraction to isolate the console from // the implementation of a particular instruction processor. type Handler interface { @@ -21,8 +15,9 @@ type Handler interface { Complete(input prompt.Document) []prompt.Suggest // Execute accept input to perform actions defined by client implementation. - Execute(input string) HandlerResult + // Expecting that result type implements Formatter interface. + Execute(input string) any - // Stop notify handler to terminate execution and close any opened streams. - Stop() // Q: А нужно ли иметь такой метод? + // Close notify handler to terminate execution and close any opened streams. + Close() } diff --git a/cli/console/history.go b/cli/console/history.go index c1cc36ea4..3bbd6f71c 100644 --- a/cli/console/history.go +++ b/cli/console/history.go @@ -9,5 +9,5 @@ type History interface { Open(fileName string, maxCommands int) error AppendCommand(input string) Command() []string - Stop() // Q: А нужно ли иметь такой метод? + Close() } diff --git a/go.mod b/go.mod index 011c725ed..c7d80a89d 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/tarantool/cartridge-cli v0.0.0-20220605082730-53e6a5be9a61 github.com/tarantool/go-prompt v1.0.1 github.com/tarantool/go-tarantool v1.12.2 + github.com/tarantool/go-tarantool/v2 v2.2.0 github.com/tarantool/tt/lib/cluster v0.0.0 github.com/tarantool/tt/lib/integrity v0.0.0 github.com/vmihailenco/msgpack/v5 v5.3.5 diff --git a/go.sum b/go.sum index c8d7cfa8f..331099dfc 100644 --- a/go.sum +++ b/go.sum @@ -427,6 +427,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tarantool/go-iproto v1.1.0 h1:HULVOIHsiehI+FnHfM7wMDntuzUddO09DKqu2WnFQ5A= +github.com/tarantool/go-iproto v1.1.0/go.mod h1:LNCtdyZxojUed8SbOiYHoc3v9NvaZTB7p96hUySMlIo= github.com/tarantool/go-openssl v0.0.8-0.20230307065445-720eeb389195/go.mod h1:M7H4xYSbzqpW/ZRBMyH0eyqQBsnhAMfsYk5mv0yid7A= github.com/tarantool/go-openssl v1.1.1 h1:qOCSjUXRLxlnh0e2G6sH50B3d/gYpscbY/opFqsIfaE= github.com/tarantool/go-openssl v1.1.1/go.mod h1:M7H4xYSbzqpW/ZRBMyH0eyqQBsnhAMfsYk5mv0yid7A= @@ -436,6 +438,8 @@ github.com/tarantool/go-prompt v1.0.1 h1:88Yer6gCFylqGRrdWwikNFVbklRQsqKF7mycvGd github.com/tarantool/go-prompt v1.0.1/go.mod h1:9Vuvi60Bk+3yaXqgYaXNTpLbwPPaaEOeaUgpFW1jqTU= github.com/tarantool/go-tarantool v1.12.2 h1:u4g+gTOHNxbUDJv0EIUFkRurU/lTQSzWrz8o7bHVAqI= github.com/tarantool/go-tarantool v1.12.2/go.mod h1:QRiXv0jnxwgxHtr9ZmifSr/eRba76gTUBgp69pDMX1U= +github.com/tarantool/go-tarantool/v2 v2.2.0 h1:U7RDvWxPaPPecMppqVwfpTGnSJQ++Crg2l9cS/ztgp8= +github.com/tarantool/go-tarantool/v2 v2.2.0/go.mod h1:hKKeZeCP8Y8+U6ZFS32ot1jHV/n4WKVP4fjRAvQznMY= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=