From 2c78fea68e07b664fbc29c25043e6ba1e297e25d Mon Sep 17 00:00:00 2001 From: Dmitrii Shelomentsev Date: Fri, 22 Mar 2024 19:07:53 +0400 Subject: [PATCH] GCLOUD2-13152: Profiles --- cmd/gcore-cli/main.go | 3 +- go.mod | 3 +- go.sum | 4 + internal/commands/commands.go | 17 +++ internal/commands/config/config.go | 149 ++++++++++++++++++++++++ internal/commands/config/dump.go | 33 ++++++ internal/commands/config/get.go | 74 ++++++++++++ internal/commands/config/info.go | 69 +++++++++++ internal/commands/config/set.go | 102 ++++++++++++++++ internal/commands/config/unset.go | 57 +++++++++ internal/commands/fastedge/app.go | 38 +++--- internal/commands/fastedge/binary.go | 16 ++- internal/commands/fastedge/fastedge.go | 35 +++--- internal/commands/fastedge/logs.go | 20 ++-- internal/commands/fastedge/plan.go | 7 +- internal/commands/fastedge/stats.go | 14 ++- internal/commands/init/init.go | 72 ++++++++++++ internal/config/config.go | 27 +++++ internal/config/types.go | 155 +++++++++++++++++++++++++ internal/core/completion.go | 24 ++++ internal/core/meta.go | 90 ++++++++++++++ internal/core/root.go | 112 +++++++++++------- 22 files changed, 1023 insertions(+), 98 deletions(-) create mode 100644 internal/commands/commands.go create mode 100644 internal/commands/config/config.go create mode 100644 internal/commands/config/dump.go create mode 100644 internal/commands/config/get.go create mode 100644 internal/commands/config/info.go create mode 100644 internal/commands/config/set.go create mode 100644 internal/commands/config/unset.go create mode 100644 internal/commands/init/init.go create mode 100644 internal/config/config.go create mode 100644 internal/config/types.go create mode 100644 internal/core/completion.go create mode 100644 internal/core/meta.go diff --git a/cmd/gcore-cli/main.go b/cmd/gcore-cli/main.go index ddacc4a..0ab2b2c 100644 --- a/cmd/gcore-cli/main.go +++ b/cmd/gcore-cli/main.go @@ -1,9 +1,10 @@ package main import ( + "github.com/G-core/gcore-cli/internal/commands" "github.com/G-core/gcore-cli/internal/core" ) func main() { - core.Execute() + core.Execute(commands.Commands()) } diff --git a/go.mod b/go.mod index e89e88c..14d0dc0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,6 @@ module github.com/G-core/gcore-cli go 1.21.5 - require ( github.com/G-Core/FastEdge-client-sdk-go v0.0.0-20240304075046-db0c8c3d17e7 github.com/alecthomas/assert v1.0.0 @@ -18,6 +17,7 @@ require ( ) require ( + github.com/AlekSi/pointer v1.2.0 // indirect github.com/alecthomas/colour v0.1.0 // indirect github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect @@ -27,6 +27,7 @@ require ( github.com/go-openapi/swag v0.22.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 0865fc5..9a0ebe2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= +github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/G-Core/FastEdge-client-sdk-go v0.0.0-20240214130448-d87df1e38764 h1:7CATrk7BJpU1t4wr2avczZaX1KrwsjhArBKBaiSQN2M= github.com/G-Core/FastEdge-client-sdk-go v0.0.0-20240214130448-d87df1e38764/go.mod h1:ggyUVhy8/OCMBY4nbm7n9qDoPioROCk4vHhDJq9w7qE= github.com/G-Core/FastEdge-client-sdk-go v0.0.0-20240304075046-db0c8c3d17e7 h1:99yyAfaF6OV2ghz75yE72LwdzxP6gUYa3hL7XkObb5s= @@ -43,6 +45,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000..d7dc2eb --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,17 @@ +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/commands/config" + "github.com/G-core/gcore-cli/internal/commands/fastedge" + initCmd "github.com/G-core/gcore-cli/internal/commands/init" +) + +func Commands() []*cobra.Command { + return []*cobra.Command{ + fastedge.Commands(), + initCmd.Commands(), + config.Commands(), + } +} diff --git a/internal/commands/config/config.go b/internal/commands/config/config.go new file mode 100644 index 0000000..72a39d7 --- /dev/null +++ b/internal/commands/config/config.go @@ -0,0 +1,149 @@ +package config + +import ( + "fmt" + "slices" + + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/config" + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/output" +) + +func Commands() *cobra.Command { + var cmd = &cobra.Command{ + Use: "config", + Short: "Config file management", + GroupID: "configuration", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand(info(), get(), set(), unset(), dump(), profileCmd()) + return cmd +} + +func profileCmd() *cobra.Command { + var cmd = &cobra.Command{ + Use: "profile", + Short: "Commands to manage profiles from the config", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand(listProfiles(), switchProfileCmd(), deleteProfileCmd()) + return cmd +} + +func deleteProfileCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"d"}, + ValidArgsFunction: core.ProfileCompletion, + Short: "Delete profile from the config", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cmd.Help() + + return nil + } + + profileName := args[0] + ctx := cmd.Context() + cfg := core.ExtractConfig(ctx) + active := core.ExtractProfile(ctx) + + _, exist := cfg.Profiles[profileName] + if exist { + delete(cfg.Profiles, profileName) + } else { + return fmt.Errorf("profile '%s' doesn't exist", profileName) + } + + if active == profileName { + cfg.ActiveProfile = config.DefaultProfile + } + + path, err := core.ExtractConfigPath(ctx) + if err != nil { + return err + } + + return cfg.Save(path) + }, + } + + return cmd +} + +func listProfiles() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Display list of available profiles in the config", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + cfg := core.ExtractConfig(ctx) + + profiles := append([]profileView{}, toProfileView(config.DefaultProfile, &cfg.Profile)) + + var names []string + for name, _ := range cfg.Profiles { + names = append(names, name) + } + slices.Sort(names) + + for _, name := range names { + pv := toProfileView(name, cfg.Profiles[name]) + + profiles = append(profiles, pv) + } + + output.Print(profiles) + + return nil + }, + } + + return cmd +} + +func switchProfileCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "switch ", + ValidArgsFunction: core.ProfileCompletion, + Short: "Make selected profile active", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cmd.Help() + + return nil + } + + profileName := args[0] + ctx := cmd.Context() + cfg := core.ExtractConfig(ctx) + + _, exist := cfg.Profiles[profileName] + if exist { + cfg.ActiveProfile = profileName + } else if profileName != config.DefaultProfile { + return fmt.Errorf("profile '%s' doesn't exist", profileName) + } else { + cfg.ActiveProfile = config.DefaultProfile + } + + path, err := core.ExtractConfigPath(ctx) + if err != nil { + return err + } + + return cfg.Save(path) + }, + } + + return cmd +} diff --git a/internal/commands/config/dump.go b/internal/commands/config/dump.go new file mode 100644 index 0000000..3477c93 --- /dev/null +++ b/internal/commands/config/dump.go @@ -0,0 +1,33 @@ +package config + +import ( + "github.com/AlekSi/pointer" + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/output" +) + +func dump() *cobra.Command { + var cmd = &cobra.Command{ + Use: "dump", + Short: "Dumps the config file", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) (err error) { + ctx := cmd.Context() + cfg := core.ExtractConfig(ctx) + + // Secure keys + cfg.Profile.ApiKey = pointer.To(secureKey(cfg.Profile.ApiKey)) + for _, profile := range cfg.Profiles { + profile.ApiKey = pointer.To(secureKey(profile.ApiKey)) + } + + output.Print(cfg) + + return nil + }, + } + + return cmd +} diff --git a/internal/commands/config/get.go b/internal/commands/config/get.go new file mode 100644 index 0000000..9f8c8a5 --- /dev/null +++ b/internal/commands/config/get.go @@ -0,0 +1,74 @@ +package config + +import ( + "fmt" + "reflect" + + "github.com/iancoleman/strcase" + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/config" + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/output" +) + +func getProfileField(profile *config.Profile, key string) (reflect.Value, error) { + field := reflect.ValueOf(profile).Elem().FieldByName(strcase.ToCamel(key)) + reflect.ValueOf(profile).Elem().FieldByNameFunc(func(s string) bool { + return key == strcase.ToKebab(s) + }) + + if !field.IsValid() { + return reflect.ValueOf(nil), fmt.Errorf("invalid key: %s", key) + } + + return field, nil +} + +func getProfileValue(profile *config.Profile, fieldName string) (interface{}, error) { + field, err := getProfileField(profile, fieldName) + if err != nil { + return nil, err + } + return field.Interface(), nil +} + +func get() *cobra.Command { + var cmd = &cobra.Command{ + Use: "get ", + Short: "Get property value from the config file", + ValidArgs: []string{"api-url", "api-key"}, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cmd.Help() + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + + ctx := cmd.Context() + profileName := core.ExtractProfile(ctx) + cfg := core.ExtractConfig(ctx) + + profile, err := cfg.GetProfile(profileName) + if err != nil { + return err + } + + value, err := getProfileValue(profile, args[0]) + if err != nil { + return err + } + + output.Print(value) + + return nil + }, + } + + return cmd +} diff --git a/internal/commands/config/info.go b/internal/commands/config/info.go new file mode 100644 index 0000000..4fabfae --- /dev/null +++ b/internal/commands/config/info.go @@ -0,0 +1,69 @@ +package config + +import ( + "strings" + + "github.com/AlekSi/pointer" + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/config" + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/output" +) + +type profileView struct { + Name string + ApiUrl *string + ApiKey *string +} + +func toProfileView(name string, profile *config.Profile) profileView { + var pv = profileView{ + Name: name, + } + + if profile.ApiUrl != nil { + pv.ApiUrl = profile.ApiUrl + } + + if profile.ApiKey != nil { + pv.ApiKey = pointer.To(secureKey(profile.ApiKey)) + } + + return pv +} + +func info() *cobra.Command { + var cmd = &cobra.Command{ + Use: "info", + Short: "Get information about config profile", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + profile, err := core.GetClientProfile(ctx) + if err != nil { + return err + } + + output.Print(toProfileView(core.ExtractProfile(ctx), profile)) + + return nil + }, + } + + return cmd +} + +func secureKey(key *string) string { + if key == nil || *key == "" { + return "" + } + + var p1 = 0 + 5 + var p2 = len(*key) - 1 - 5 + if p1 > p2 { + return "XXXXXX" + } + + return strings.Join([]string{(*key)[0:p1], "XXXXXX", (*key)[p2 : len((*key))-1]}, "") +} diff --git a/internal/commands/config/set.go b/internal/commands/config/set.go new file mode 100644 index 0000000..17d17a3 --- /dev/null +++ b/internal/commands/config/set.go @@ -0,0 +1,102 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/AlekSi/pointer" + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/config" + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/output" +) + +func set() *cobra.Command { + var p config.Profile + var cmd = &cobra.Command{ + Use: "set =", + Short: "Set property for active profile", + Long: "This commands overwrites the configuration file parameters with user input.\n" + + "The only allowed arguments are: api-url, api-key", + ValidArgs: []string{"api-url", "api-key"}, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cmd.Help() + + return nil + } + + var m = make(map[string]any) + for _, arg := range args { + ss := strings.Split(arg, "=") + if len(ss) != 2 { + continue + } + + name, value := ss[0], ss[1] + // TODO: reflection here + switch name { + case "api-url", "api-key": + m[name] = &value + } + } + + if len(m) == 0 { + return fmt.Errorf("invalid arguments") + } + + for name, value := range m { + switch name { + case "api-url": + p.ApiUrl = value.(*string) + case "api-key": + p.ApiKey = value.(*string) + } + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + + ctx := cmd.Context() + cfg := core.ExtractConfig(ctx) + profileName := core.ExtractProfile(ctx) + profile := &cfg.Profile + if profileName != config.DefaultProfile { + var exist bool + profile, exist = cfg.Profiles[profileName] + if !exist { + if cfg.Profiles == nil { + cfg.Profiles = map[string]*config.Profile{} + } + cfg.Profiles[profileName] = &config.Profile{} + profile = cfg.Profiles[profileName] + } + } + + profile = config.MergeProfiles(profile, &p) + cfg.SetProfile(profileName, profile) + + path, err := core.ExtractConfigPath(ctx) + if err != nil { + return err + } + + if err := cfg.Save(path); err != nil { + return err + } + + profile, _ = cfg.GetProfile(profileName) + profile.ApiKey = pointer.To(secureKey(profile.ApiKey)) + output.Print(profile) + + return nil + }, + } + + return cmd +} diff --git a/internal/commands/config/unset.go b/internal/commands/config/unset.go new file mode 100644 index 0000000..1632a0c --- /dev/null +++ b/internal/commands/config/unset.go @@ -0,0 +1,57 @@ +package config + +import ( + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/output" +) + +func unset() *cobra.Command { + var cmd = &cobra.Command{ + Use: "unset ", + Short: "Unset a line from the config file", + Long: "Unset a line from the config file.\n" + + "The only allowed arguments are: api-url, api-key", + ValidArgs: []string{"api-url", "api-key"}, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + cmd.Help() + + return nil + } + + ctx := cmd.Context() + profileName := core.ExtractProfile(ctx) + cfg := core.ExtractConfig(ctx) + profile, err := cfg.GetProfile(profileName) + if err != nil { + return err + } + + for _, name := range args { + switch name { + case "api-url": + profile.ApiUrl = nil + case "api-key": + profile.ApiKey = nil + } + } + + cfg.SetProfile(profileName, profile) + path, err := core.ExtractConfigPath(ctx) + if err != nil { + return err + } + + if err := cfg.Save(path); err != nil { + return err + } + + output.Print(profile) + return nil + }, + } + + return cmd +} diff --git a/internal/commands/fastedge/app.go b/internal/commands/fastedge/app.go index b0b2646..6a26f5b 100644 --- a/internal/commands/fastedge/app.go +++ b/internal/commands/fastedge/app.go @@ -34,6 +34,7 @@ You can use either previously-uploaded binary, by specifying "--binary ", or uploading binary using "--file ". To load file from stdin, use "-" as filename`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() app, err := parseAppProperties(cmd) if err != nil { return err @@ -49,14 +50,14 @@ uploading binary using "--file ". To load file from stdin, use "-" as if file == "" { return errors.New("binary must be specified either using --binary or --file ") } - id, err := uploadBinary(file) + id, err := uploadBinary(ctx, file) if err != nil { return err } app.Binary = &id } - rsp, err := client.AddAppWithResponse(context.Background(), app) + rsp, err := client.AddAppWithResponse(ctx, app) if err != nil { return fmt.Errorf("adding the app: %w", err) } @@ -92,7 +93,8 @@ You can use either previously-uploaded binary, by specifying "--binary ", or uploading binary using "--file ". To load file from stdin, use "-" as filename`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } @@ -107,7 +109,7 @@ uploading binary using "--file ". To load file from stdin, use "-" as return fmt.Errorf("cannot parse file name: %w", err) } if file != "" { - id, err := uploadBinary(file) + id, err := uploadBinary(ctx, file) if err != nil { return err } @@ -119,7 +121,7 @@ uploading binary using "--file ". To load file from stdin, use "-" as return e.ErrAborted } - rsp, err := client.PatchAppWithResponse(context.Background(), id, app) + rsp, err := client.PatchAppWithResponse(ctx, id, app) if err != nil { return fmt.Errorf("updating the app: %w", err) } @@ -150,7 +152,7 @@ uploading binary using "--file ". To load file from stdin, use "-" as Short: "Show list of client's apps", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - rsp, err := client.ListAppsWithResponse(context.Background()) + rsp, err := client.ListAppsWithResponse(cmd.Context()) if err != nil { return fmt.Errorf("getting the list of apps: %w", err) } @@ -192,12 +194,13 @@ To see statistics, use "fastedge stat app_calls" and "fastedge stat app_duration commands.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } rsp, err := client.GetAppWithResponse( - context.Background(), + ctx, id, ) if err != nil { @@ -234,12 +237,13 @@ commands.`, Short: "Enable the app", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } rsp, err := client.PatchAppWithResponse( - context.Background(), + ctx, id, sdk.App{Status: newPointer(1)}, ) @@ -265,12 +269,13 @@ commands.`, Short: "Disable the app", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } rsp, err := client.PatchAppWithResponse( - context.Background(), + ctx, id, sdk.App{Status: newPointer(2)}, ) @@ -300,7 +305,8 @@ however binaries, not referenced by any app, get deleted by cleanup process regu so if you don't want this to happen, consider disabling the app to keep binary referenced`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } @@ -309,7 +315,7 @@ so if you don't want this to happen, consider disabling the app to keep binary r return e.ErrAborted } - rsp, err := client.DelAppWithResponse(context.Background(), id) + rsp, err := client.DelAppWithResponse(ctx, id) if err != nil { return fmt.Errorf("deleting app: %w", err) } @@ -446,8 +452,8 @@ func outputMap(m *map[string]string, title string) { } } -func getAppIdByName(appName string) (int64, error) { - idRsp, err := client.GetAppIdByNameWithResponse(context.Background(), appName) +func getAppIdByName(ctx context.Context, appName string) (int64, error) { + idRsp, err := client.GetAppIdByNameWithResponse(ctx, appName) if err != nil { return 0, fmt.Errorf("api response: %w", err) } diff --git a/internal/commands/fastedge/binary.go b/internal/commands/fastedge/binary.go index c489bce..c69c112 100644 --- a/internal/commands/fastedge/binary.go +++ b/internal/commands/fastedge/binary.go @@ -31,7 +31,8 @@ func binary() *cobra.Command { Short: "Show list of client's binaries", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - rsp, err := client.ListBinariesWithResponse(context.Background()) + ctx := cmd.Context() + rsp, err := client.ListBinariesWithResponse(ctx) if err != nil { return fmt.Errorf("getting the list of binaries: %w", err) } @@ -72,12 +73,13 @@ func binary() *cobra.Command { If this flag is omitted, file contant is read from stdin.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() src, err := cmd.Flags().GetString("file") if err != nil { return errors.New("please specify binary filename") } - id, err := uploadBinary(src) + id, err := uploadBinary(ctx, src) if err != nil { return err } @@ -95,12 +97,13 @@ If this flag is omitted, file contant is read from stdin.`, Short: "Show binary details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() id, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return fmt.Errorf("parsing binary id: %w", err) } - rsp, err := client.GetBinaryWithResponse(context.Background(), id) + rsp, err := client.GetBinaryWithResponse(ctx, id) if err != nil { return fmt.Errorf("getting the list of plans: %w", err) } @@ -136,12 +139,13 @@ If this flag is omitted, file contant is read from stdin.`, Long: `Delete the binary. Binary cannot be deleted if it is still referenced by any app.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() id, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return fmt.Errorf("parsing binary id: %w", err) } - rsp, err := client.DelBinaryWithResponse(context.Background(), id) + rsp, err := client.DelBinaryWithResponse(ctx, id) if err != nil { return fmt.Errorf("getting the list of plans: %w", err) } @@ -164,7 +168,7 @@ If this flag is omitted, file contant is read from stdin.`, return cmdBin } -func uploadBinary(src string) (int64, error) { +func uploadBinary(ctx context.Context, src string) (int64, error) { r := os.Stdin var err error if src != sourceStdin { @@ -176,7 +180,7 @@ func uploadBinary(src string) (int64, error) { } rsp, err := client.StoreBinaryWithBodyWithResponse( - context.Background(), + ctx, wasmContentType, r, ) diff --git a/internal/commands/fastedge/fastedge.go b/internal/commands/fastedge/fastedge.go index cd0caff..f8ada3f 100644 --- a/internal/commands/fastedge/fastedge.go +++ b/internal/commands/fastedge/fastedge.go @@ -1,32 +1,41 @@ package fastedge import ( - "context" "fmt" - "net/http" "github.com/golang-module/carbon/v2" "github.com/spf13/cobra" sdk "github.com/G-Core/FastEdge-client-sdk-go" + "github.com/G-core/gcore-cli/internal/core" ) var client *sdk.ClientWithResponses // top-level FastEdge command -func Commands(baseUrl string, authFunc func(ctx context.Context, req *http.Request) error) (*cobra.Command, error) { - var local bool +func Commands() *cobra.Command { var cmdFastedge = &cobra.Command{ - Use: "fastedge ", - Short: "Gcore Edge compute solution", - Long: ``, - Args: cobra.MinimumNArgs(1), + Use: "fastedge ", + Short: "Gcore Edge compute solution", + Long: ``, + GroupID: "fastedge", + Args: cobra.MinimumNArgs(1), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - var err error - url := baseUrl - if !local { + var ( + err error + ctx = cmd.Context() + ) + profile, err := core.GetClientProfile(ctx) + if err != nil { + return err + } + url := *profile.ApiUrl + authFunc := core.ExtractAuthFunc(ctx) + + if !profile.IsLocal() { url += "/fastedge" } + client, err = sdk.NewClientWithResponses( url, sdk.WithRequestEditorFn(authFunc), @@ -43,11 +52,9 @@ func Commands(baseUrl string, authFunc func(ctx context.Context, req *http.Reque return nil }, } - cmdFastedge.PersistentFlags().BoolVar(&local, "local", false, "local testing") - cmdFastedge.PersistentFlags().MarkHidden("local") cmdFastedge.AddCommand(app(), binary(), plan(), stat(), logs()) - return cmdFastedge, nil + return cmdFastedge } func newPointer[T any](val T) *T { diff --git a/internal/commands/fastedge/logs.go b/internal/commands/fastedge/logs.go index 809f012..adc31c4 100644 --- a/internal/commands/fastedge/logs.go +++ b/internal/commands/fastedge/logs.go @@ -2,7 +2,6 @@ package fastedge import ( "bufio" - "context" "errors" "fmt" "net/http" @@ -89,13 +88,14 @@ This command allows you filtering by edge name, client ip and time range.`, return nil }, RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } rsp, err := client.GetV1AppsIdLogsWithResponse( - context.Background(), + ctx, id, &sdk.GetV1AppsIdLogsParams{ From: &from, @@ -143,7 +143,7 @@ This command allows you filtering by edge name, client ip and time range.`, // Call the API again with the new page number rsp, err = client.GetV1AppsIdLogsWithResponse( - context.Background(), + ctx, id, &sdk.GetV1AppsIdLogsParams{ From: &from, @@ -174,12 +174,13 @@ This command allows you filtering by edge name, client ip and time range.`, Short: "Enable app logging", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } rsp, err := client.PatchAppWithResponse( - context.Background(), + ctx, id, sdk.App{Debug: newPointer(true)}, ) @@ -191,7 +192,7 @@ This command allows you filtering by edge name, client ip and time range.`, } rsp1, err := client.GetAppWithResponse( - context.Background(), + ctx, id, ) if err != nil { @@ -215,12 +216,13 @@ This command allows you filtering by edge name, client ip and time range.`, Short: "Disable app logging", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } rsp, err := client.PatchAppWithResponse( - context.Background(), + ctx, id, sdk.App{Debug: newPointer(false)}, ) diff --git a/internal/commands/fastedge/plan.go b/internal/commands/fastedge/plan.go index e5cd01f..f049188 100644 --- a/internal/commands/fastedge/plan.go +++ b/internal/commands/fastedge/plan.go @@ -1,7 +1,6 @@ package fastedge import ( - "context" "fmt" "net/http" "time" @@ -28,7 +27,8 @@ may result in excessive timeouts and out-of-memory errors.`, Short: "Show list of available plans", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - rsp, err := client.ListPlansWithResponse(context.Background()) + ctx := cmd.Context() + rsp, err := client.ListPlansWithResponse(ctx) if err != nil { return fmt.Errorf("getting the list of plans: %w", err) } @@ -61,8 +61,9 @@ may result in excessive timeouts and out-of-memory errors.`, Short: "Show plan details", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() rsp, err := client.GetPlanWithResponse( - context.Background(), + ctx, args[0], ) if err != nil { diff --git a/internal/commands/fastedge/stats.go b/internal/commands/fastedge/stats.go index 56644d4..84cbc68 100644 --- a/internal/commands/fastedge/stats.go +++ b/internal/commands/fastedge/stats.go @@ -1,7 +1,6 @@ package fastedge import ( - "context" "fmt" "net/http" "slices" @@ -21,7 +20,8 @@ func stat() *cobra.Command { Short: "Statistics", Args: cobra.MinimumNArgs(0), RunE: func(cmd *cobra.Command, args []string) error { - rsp, err := client.GetClientMeWithResponse(context.Background()) + ctx := cmd.Context() + rsp, err := client.GetClientMeWithResponse(ctx) if err != nil { return fmt.Errorf("getting the statistics: %w", err) } @@ -67,7 +67,8 @@ can be omitted, or as UNIX timestamp) and reporting step duration with flag "--step" (in seconds).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } @@ -88,7 +89,7 @@ can be omitted, or as UNIX timestamp) and reporting step duration with flag } rsp, err := client.AppCallsWithResponse( - context.Background(), + ctx, id, &sdk.AppCallsParams{ From: from, @@ -180,7 +181,8 @@ can be omitted, or as UNIX timestamp) and reporting step duration with flag "--step" (in seconds).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := getAppIdByName(args[0]) + ctx := cmd.Context() + id, err := getAppIdByName(ctx, args[0]) if err != nil { return fmt.Errorf("cannot find app by name: %w", err) } @@ -201,7 +203,7 @@ can be omitted, or as UNIX timestamp) and reporting step duration with flag } rsp, err := client.AppDurationWithResponse( - context.Background(), + ctx, id, &sdk.AppDurationParams{ From: from, diff --git a/internal/commands/init/init.go b/internal/commands/init/init.go new file mode 100644 index 0000000..525654f --- /dev/null +++ b/internal/commands/init/init.go @@ -0,0 +1,72 @@ +package init + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/config" + "github.com/G-core/gcore-cli/internal/core" + "github.com/G-core/gcore-cli/internal/errors" + "github.com/G-core/gcore-cli/internal/sure" +) + +func Commands() *cobra.Command { + var cmd = &cobra.Command{ + Use: "init ", + Short: "Initialize the config for gcore-cli", + Long: `Initialize the active profile of the config. +Default path for configuration file is based on the following priority order: +- $GCORE_CONFIG +- $HOME/.gcorecli/config.yaml +`, + GroupID: "configuration", + Example: "gcore init -p prod", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + profileName := core.ExtractProfile(ctx) + cfg := core.ExtractConfig(ctx) + + var profile = &cfg.Profile + if profileName != config.DefaultProfile { + _, found := cfg.Profiles[profileName] + if !found { + if cfg.Profiles == nil { + cfg.Profiles = make(map[string]*config.Profile) + } + cfg.Profiles[profileName] = &config.Profile{} + } + profile = cfg.Profiles[profileName] + } + + // TODO: Interactive output should be in stderror + if !sure.AreYou(cmd, fmt.Sprintf("overwrite profile '%s'", profileName)) { + return errors.ErrAborted + } + + profile.ApiKey = askForApiKey(cmd) + path, err := core.ExtractConfigPath(ctx) + if err != nil { + return err + } + + return cfg.Save(path) + }, + } + + cmd.PersistentFlags().String("apikey", "", "GCore API key") + + return cmd +} + +func askForApiKey(cmd *cobra.Command) *string { + apikey, _ := cmd.PersistentFlags().GetString("apikey") + if apikey == "" { + fmt.Printf("Please, enter API key: ") + fmt.Scanf("%s", &apikey) + } + + return &apikey +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..43709bd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,27 @@ +package config + +import ( + "fmt" + "os" + "path" +) + +const CliConfigFile = "config.yaml" + +func getConfigHomeDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting user home directory: %w", err) + } + + return path.Join(home, ".gcorecli"), nil +} + +func GetConfigPath() (string, error) { + configDir, err := getConfigHomeDir() + if err != nil { + return "", err + } + + return path.Join(configDir, CliConfigFile), nil +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..c0ede9a --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,155 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/AlekSi/pointer" + "gopkg.in/yaml.v3" +) + +const ( + DefaultProfile = "default" + DefaultAPI = "https://api.gcore.com" +) + +const ( + EnvConfigPath = "GCORE_CONFIG" + EnvConfigProfile = "GCORE_PROFILE" + EnvProfileURL = "GCORE_API_URL" + EnvProfileAPIKey = "GCORE_API_KEY" +) + +type Profile struct { + ApiUrl *string `yaml:"api-url,omitempty" json:"api-url,omitempty"` + ApiKey *string `yaml:"api-key,omitempty" json:"api-key,omitempty"` +} + +func (p *Profile) IsLocal() bool { + if p.ApiUrl == nil { + return false + } + + if *p.ApiUrl == DefaultAPI { + return false + } + + return true +} + +type Config struct { + Profile `yaml:",inline"` + ActiveProfile string `yaml:"profile" json:"profile,omitempty"` + Profiles map[string]*Profile `yaml:"profiles,omitempty" json:"profiles,omitempty"` +} + +func NewDefault() *Config { + return &Config{ + ActiveProfile: DefaultProfile, + Profile: Profile{ + ApiUrl: pointer.To(DefaultAPI), + }, + } +} + +func (c *Config) String() string { + body, _ := yaml.Marshal(c) + + return string(body) +} + +func (c *Config) Load(path string) error { + body, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + if err := yaml.Unmarshal(body, c); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + + return nil +} + +func (c *Config) Save(path string) error { + body, err := yaml.Marshal(*c) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + if err := os.WriteFile(path, body, 0644); err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + + return nil +} + +func (c *Config) GetProfile(name string) (*Profile, error) { + if name == DefaultProfile { + return &c.Profile, nil + } + + if c.Profiles == nil { + return nil, fmt.Errorf("profile '%s' isn't exist", name) + } + + p, exist := c.Profiles[name] + if !exist { + return nil, fmt.Errorf("profile '%s' isn't exist", name) + } + + return MergeProfiles(&c.Profile, p), nil +} + +func (c *Config) SetProfile(name string, profile *Profile) { + if name == DefaultProfile { + c.Profile = *profile + + return + } + + if c.Profiles == nil { + c.Profiles = map[string]*Profile{} + } + + c.Profiles[name] = profile +} + +func GetEnvProfile() *Profile { + var profile Profile + + if url := os.Getenv(EnvProfileURL); url != "" { + profile.ApiUrl = &url + } + + if apiKey := os.Getenv(EnvProfileAPIKey); apiKey != "" { + profile.ApiKey = &apiKey + } + + return &profile +} + +func MergeProfiles(original *Profile, profiles ...*Profile) *Profile { + var result = &Profile{ + ApiKey: original.ApiKey, + ApiUrl: original.ApiUrl, + } + + for _, profile := range profiles { + if profile.ApiKey != nil { + result.ApiKey = pointer.To(*profile.ApiKey) + } + + if profile.ApiUrl != nil { + result.ApiUrl = pointer.To(*profile.ApiUrl) + } + } + + return result +} diff --git a/internal/core/completion.go b/internal/core/completion.go new file mode 100644 index 0000000..28579a0 --- /dev/null +++ b/internal/core/completion.go @@ -0,0 +1,24 @@ +package core + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/G-core/gcore-cli/internal/config" +) + +func ProfileCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + + cfg := ExtractConfig(ctx) + var completions []string + completions = append(completions, config.DefaultProfile) + for name, _ := range cfg.Profiles { + if strings.HasPrefix(name, toComplete) { + completions = append(completions, name) + } + } + + return completions, cobra.ShellCompDirectiveDefault +} diff --git a/internal/core/meta.go b/internal/core/meta.go new file mode 100644 index 0000000..7e256b7 --- /dev/null +++ b/internal/core/meta.go @@ -0,0 +1,90 @@ +package core + +import ( + "context" + "net/http" + "os" + + "github.com/G-core/gcore-cli/internal/config" +) + +const metaKey = iota + +// meta contains information about global flags and cli configuration +type meta struct { + cfg *config.Config + ctx context.Context + + // Global flags + flagConfig string + flagProfile string + flagForce bool + flagWait bool + + // Auth function + authFunc func(ctx context.Context, req *http.Request) error +} + +func injectMeta(ctx context.Context, m meta) context.Context { + return context.WithValue(ctx, metaKey, m) +} + +func extractMeta(ctx context.Context) meta { + return ctx.Value(metaKey).(meta) +} + +func ExtractConfig(ctx context.Context) *config.Config { + return extractMeta(ctx).cfg +} + +func ExtractConfigPath(ctx context.Context) (string, error) { + path := extractMeta(ctx).flagConfig + if len(path) != 0 { + return path, nil + } + + path = os.Getenv(config.EnvConfigPath) + if len(path) != 0 { + return path, nil + } + + return config.GetConfigPath() +} + +func ExtractProfile(ctx context.Context) string { + profileName := extractMeta(ctx).flagProfile + if len(profileName) > 0 { + return profileName + } + + profile := os.Getenv(config.EnvConfigProfile) + if len(profile) > 0 { + return profile + } + + cfg := ExtractConfig(ctx) + if len(cfg.ActiveProfile) > 0 { + return cfg.ActiveProfile + } + + return config.DefaultProfile +} + +// GetClientProfile returns current profile for client merged from config, envs and flag variables +func GetClientProfile(ctx context.Context) (*config.Profile, error) { + name := ExtractProfile(ctx) + cfg := ExtractConfig(ctx) + + profile, err := cfg.GetProfile(name) + if err != nil { + return nil, err + } + + envProfile := config.GetEnvProfile() + + return config.MergeProfiles(profile, envProfile), nil +} + +func ExtractAuthFunc(ctx context.Context) func(ctx context.Context, req *http.Request) error { + return extractMeta(ctx).authFunc +} diff --git a/internal/core/root.go b/internal/core/root.go index 2409a9f..4913390 100644 --- a/internal/core/root.go +++ b/internal/core/root.go @@ -8,63 +8,81 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" - "github.com/G-core/gcore-cli/internal/commands/fastedge" + "github.com/G-core/gcore-cli/internal/config" "github.com/G-core/gcore-cli/internal/errors" "github.com/G-core/gcore-cli/internal/human" "github.com/G-core/gcore-cli/internal/output" ) -func Execute() { +func init() { + cobra.EnableCommandSorting = false +} + +func Execute(commands []*cobra.Command) { var rootCmd = &cobra.Command{ // TODO: pick name from binary name - Use: "gcore-cli", + Use: os.Args[0], SilenceUsage: true, SilenceErrors: true, } + var meta meta + // global flags, applicable to all sub-commands - apiKey := rootCmd.PersistentFlags().StringP("apikey", "a", "", "API key") - apiUrl := rootCmd.PersistentFlags().StringP("url", "u", "https://api.gcore.com", "API URL") - rootCmd.PersistentFlags().BoolP("force", "f", false, `Assume answer "yes" to all "are you sure?" questions`) - rootCmd.PersistentFlags().IntP("project", "", 0, "Cloud project ID") - rootCmd.PersistentFlags().IntP("region", "", 0, "Cloud region ID") - rootCmd.PersistentFlags().BoolP("wait", "", false, "Wait for command result") + rootCmd.PersistentFlags().StringVarP(&meta.flagConfig, "config", "c", "", "The path to the config file") + rootCmd.PersistentFlags().BoolVarP(&meta.flagForce, "force", "f", false, `Assume answer "yes" to all "are you sure?" questions`) + rootCmd.PersistentFlags().StringVarP(&meta.flagProfile, "profile", "p", "", "The config profile to use") + rootCmd.RegisterFlagCompletionFunc("profile", ProfileCompletion) + rootCmd.PersistentFlags().BoolVarP(&meta.flagWait, "wait", "w", false, "Wait for command result") + output.FormatOption(rootCmd) rootCmd.ParseFlags(os.Args[1:]) - v := viper.New() - v.SetEnvPrefix("gcore") - v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - v.AutomaticEnv() - bindFlags(rootCmd, v) + meta.cfg = GetConfig() + meta.authFunc = func(ctx context.Context, req *http.Request) error { + profile, err := GetClientProfile(ctx) + if err != nil { + return err + } - authFunc := func(ctx context.Context, req *http.Request) error { - req.Header.Set("Authorization", "APIKey "+*apiKey) + if profile.ApiKey == nil || *profile.ApiKey == "" { + return &errors.CliError{ + Err: fmt.Errorf("subcommand requires authorization"), + Hint: "See gcore-cli init, gcore-cli config", + } + } + + req.Header.Set("Authorization", "APIKey "+*profile.ApiKey) return nil } + meta.ctx = injectMeta(context.Background(), meta) + rootCmd.SetContext(meta.ctx) + rootCmd.AddGroup(&cobra.Group{ + ID: "fastedge", + Title: "FastEdge commands", + }, &cobra.Group{ + ID: "configuration", + Title: "Configuration commands", + }) + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - for _, safeCmd := range []string{"completion", "help"} { + for _, safeCmd := range []string{"init", "config", "completion", "help"} { if strings.Contains(cmd.CommandPath(), safeCmd) { return nil } } - if *apiUrl == "" { - return &errors.CliError{ - Message: "URL for API isn't specified", - Hint: "You can specify it by -u flag or GCORE_URL env variable", - Code: 1, - } + + profile, err := GetClientProfile(cmd.Context()) + if err != nil { + return err } - if *apiKey == "" { + if profile.ApiUrl == nil && *profile.ApiUrl == "" { return &errors.CliError{ - Message: "API key must be specified", - Hint: "You can specify it with -a flag or GCORE_APIKEY env variable.\n" + - "To get an APIKEY visit https://accounts.gcore.com/profile/api-tokens", + Err: fmt.Errorf("URL for API isn't specified"), + Hint: "You can specify it by -u flag or GCORE_URL env variable", Code: 1, } } @@ -72,14 +90,11 @@ func Execute() { return nil } - fastedgeCmd, err := fastedge.Commands(*apiUrl, authFunc) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed: %v\n", err) - os.Exit(1) + for _, command := range commands { + rootCmd.AddCommand(command) } - rootCmd.AddCommand(fastedgeCmd) - err = rootCmd.Execute() + err := rootCmd.Execute() if err != nil { cliErr, ok := err.(*errors.CliError) if !ok { @@ -93,12 +108,25 @@ func Execute() { } } -func bindFlags(cmd *cobra.Command, v *viper.Viper) { - cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { - // Apply the viper config value to the flag when the flag is not set and viper has a value - if !f.Changed && v.IsSet(f.Name) { - val := v.Get(f.Name) - cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) +// GetConfig tries to load config from $HOME dir. +// If config doesn't exist - returns default config. +func GetConfig() *config.Config { + var ( + err error + cfg config.Config + ) + + path := os.Getenv(config.EnvConfigPath) + if len(path) == 0 { + path, err = config.GetConfigPath() + if err != nil { + return config.NewDefault() } - }) + } + + if err := cfg.Load(path); err != nil { + return config.NewDefault() + } + + return &cfg }