diff --git a/.dockerignore b/.dockerignore index 88a29d95..627e267c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ # Autogenerated by makego. DO NOT EDIT. .env/ +.idea/ .tmp/ .vscode/ cmd/lekko/lekko diff --git a/.gitignore b/.gitignore index f3058996..86df1a1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Autogenerated by makego. DO NOT EDIT. /.env/ +/.idea/ /.tmp/ /.vscode/ /cmd/lekko/lekko diff --git a/Makefile b/Makefile index 9b2a7ae2..d594ba7d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ PROJECT := cli GO_MODULE := github.com/lekkodev/cli DOCKER_ORG := lekko DOCKER_PROJECT := cli -FILE_IGNORES := .vscode/ +FILE_IGNORES := .vscode/ .idea/ include make/cli/all.mk diff --git a/cmd/lekko/README.md b/cmd/lekko/README.md new file mode 100644 index 00000000..b1dc61c8 --- /dev/null +++ b/cmd/lekko/README.md @@ -0,0 +1,55 @@ +# lekko CLI + +## Design Principles + +- Commands can be executed in the interactive mode (within an interactive terminal) as well as +in a non-interactive mode (called from another program or script). Exception to the rule could be the auth commands, as +they require in some cases cli-browser interaction. +- Arguments which are essential to the proper execution of the command must be passed as positional arguments. +That applies both to the mandatory and optional arguments. Flags are to be used as modifiers or filters. (This rule has +been derived from analyzing structure of linux shell commands as well as modern cli programs such as docker and git) + +- In the non-interactive mode: + - interactive prompts for missing information is disabled. If the mandatory arguments are missing or they are + determined as malformed, the lekko cli immediately exits with error code 1 + - colors are disabled + - the lekko cli needs to be called with -i=false or --interactive=false flags to run in the non-interactive mode + +- In the interactive mode, if mandatory/optional parameters are not provided on the command line, the user will be prompted to enter them. +Interactive mode is the default mode of execution. The fallback that prompts the user for the required info is a useful, +user-friendly feature of modern CLIs, and should be supported in the interactive mode (together with the consistent +use of of colors to improve readability) + +- Providing the user with sufficient information is one of the goals while constructing modern cli programs. Generally +giving the user ample of information of what the command does or regarding the nature of the execution results is encouraged. +- To support the ease and efficiency of parsing, the "quiet" (-q or --quite ) flag can be used to request that only +essential info, in the simplest, practical format should be produced as output. +- Verbose as well as dry-run options should be available when it is practical and useful + + +### Changes introduced to lekko cli +- adding non-interactive mode +- adding positional arguments and replacing flags with positional arguments when appropriate +- adding quiet mode for majority of commands +- hiding lekko backend url flag +- normalizing the cli "menus" +- checking for number of passed arguments and generating suitable errors depending on interactive or non-interactive contexts +- including generation of complete helloworld examples in go +- refactoring the code + +### Pending changes +- more informative helps +- deciding/implementing more consistent formats for output (in addition to the quite mode/format) - text, lists, tables, json + +- ### More info needed +- discussing potential changes to some features + - auth commands + - should there be a non-interactive mode implemented there. ANSWER: will look into it later + - upgrade (key is not needed). DECISION: remove. DONE + - k8s functionality. DECISION: remove. DONE + - auto-complete, DECISION: remove for the time being. DONE + - adding team delete be implemented(?). DECISION: work on it later + - auth commands: DECISION: no changes planned for the time being +- more info needed regarding: + - repo init + - should --no-colors flags be added diff --git a/cmd/lekko/apikey.go b/cmd/lekko/apikey.go index 70db1494..e3c2ab0e 100644 --- a/cmd/lekko/apikey.go +++ b/cmd/lekko/apikey.go @@ -17,6 +17,7 @@ package main import ( "fmt" "os" + "strings" "text/tabwriter" bffv1beta1 "buf.build/gen/go/lekkodev/cli/protocolbuffers/go/lekko/bff/v1beta1" @@ -32,7 +33,7 @@ import ( func apikeyCmd() *cobra.Command { cmd := &cobra.Command{ Use: "apikey", - Short: "api key management", + Short: "Api key management", } cmd.AddCommand( createAPIKeyCmd(), @@ -44,40 +45,62 @@ func apikeyCmd() *cobra.Command { } func createAPIKeyCmd() *cobra.Command { - var name string + var name, key string + var isQuiet, isDryRun bool + var err error cmd := &cobra.Command{ - Use: "create", - Short: "Create an api key", + Short: "Create an api key", + Use: formCmdUse("create", "apikey-name"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + rArgs, n := getNArgs(1, args) + name = rArgs[0] + if n < 1 && !IsInteractive { + return errors.New("ApiKey name is required in non-interactive mode") + } + rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) a := apikey.NewAPIKey(lekko.NewBFFClient(rs)) if len(name) == 0 { - if err := survey.AskOne(&survey.Input{ + if err = survey.AskOne(&survey.Input{ Message: "Name:", Help: "Name to give the api key", }, &name); err != nil { return errors.Wrap(err, "prompt") } } - fmt.Printf("Generating api key named '%s' for team '%s'...\n", name, rs.GetLekkoTeam()) - key, err := a.Create(cmd.Context(), name) - if err != nil { - return err + if !isQuiet { + printLinef(cmd, "Generating api key named '%s' for team '%s'...\n", name, rs.GetLekkoTeam()) + } + + if !isDryRun { + if key, err = a.Create(cmd.Context(), name); err != nil { + return err + } + } else { + key = "xxxxxxxxxxx" + } + + if !isQuiet { + printLinef(cmd, "Generated api key:\n\t%s\n", logging.Bold(key)) + printLinef(cmd, "Please save the key somewhere safe, as you will not be able to access it again.\n") + printLinef(cmd, "Avoid sharing the key unnecessarily or storing it anywhere insecure.\n") + } else { + printLinef(cmd, "%s", key) } - fmt.Printf("Generated api key:\n\t%s\n", logging.Bold(key)) - fmt.Printf("Please save the key somewhere safe, as you will not be able to access it again.\n") - fmt.Printf("Avoid sharing the key unnecessarily or storing it anywhere insecure.\n") return nil }, } - cmd.Flags().StringVarP(&name, "name", "n", "", "Name to give the new api key") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) return cmd } func listAPIKeysCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "list", - Short: "List all api keys for the currently active team", + Short: "List all api keys for the currently active team", + Use: formCmdUse("list"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) a := apikey.NewAPIKey(lekko.NewBFFClient(rs)) @@ -85,30 +108,49 @@ func listAPIKeysCmd() *cobra.Command { if err != nil { return errors.Wrap(err, "list") } - printAPIKeys(keys...) + printAPIKeys(cmd, keys...) return nil }, } + cmd.Flags().BoolP(QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) return cmd } -func printAPIKeys(keys ...*bffv1beta1.APIKey) { - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - fmt.Fprintf(w, "Team\tName\tCreated By\tCreated At\n") - for _, key := range keys { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", key.TeamName, key.Nickname, key.CreatedBy, key.CreatedAt.AsTime()) +func printAPIKeys(cmd *cobra.Command, keys ...*bffv1beta1.APIKey) { + if isQuiet, _ := cmd.Flags().GetBool(QuietModeFlag); isQuiet { + keysStr := "" + for _, key := range keys { + keysStr += fmt.Sprintf("%s ", key.Nickname) + } + printLinef(cmd, "%s", strings.TrimSpace(keysStr)) + } else { + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintf(w, "Team\tName\tCreated By\tCreated At\n") + for _, key := range keys { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", key.TeamName, key.Nickname, key.CreatedBy, key.CreatedAt.AsTime()) + } + w.Flush() } - w.Flush() } func checkAPIKeyCmd() *cobra.Command { var key string + var isQuiet bool cmd := &cobra.Command{ - Use: "check", - Short: "Check an api key to ensure it can be used to authenticate with lekko", + Short: "Check an api key to ensure it can be used to authenticate with lekko", + Use: formCmdUse("check", "apikey"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) a := apikey.NewAPIKey(lekko.NewBFFClient(rs)) + if len(args) < 1 { + if !IsInteractive { + return errors.New("ApiKey is required.") + } + } else { + key = args[0] + } + if len(key) == 0 { if err := survey.AskOne(&survey.Input{ Message: "API Key:", @@ -123,21 +165,32 @@ func checkAPIKeyCmd() *cobra.Command { fmt.Printf("Lekko: Unauthenticated %s\n", logging.Red("✖")) return errors.Wrap(err, "check") } - fmt.Printf("Lekko: Authenticated %s\n", logging.Green("✔")) - printAPIKeys(lekkoKey) + if !isQuiet { + fmt.Printf("Lekko: Authenticated %s\n", logging.Green("✔")) + printAPIKeys(cmd, lekkoKey) + } else { + printLinef(cmd, "OK") + } return nil }, } - cmd.Flags().StringVarP(&key, "key", "k", "", "api key to check authentication for") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) return cmd } func deleteAPIKeyCmd() *cobra.Command { var name string + var isDryRun, isQuiet, isForce bool cmd := &cobra.Command{ - Use: "delete", - Short: "Delete an api key", + Short: "Delete an api key", + Use: formCmdUse("delete", "apikey-name"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + rArgs, n := getNArgs(1, args) + name = rArgs[0] + if n < 1 && !IsInteractive { + return errors.New("ApiKey is required in a non-interactive mode") + } rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) a := apikey.NewAPIKey(lekko.NewBFFClient(rs)) if len(name) == 0 { @@ -157,17 +210,29 @@ func deleteAPIKeyCmd() *cobra.Command { return errors.Wrap(err, "prompt") } } - fmt.Printf("Deleting api key '%s' in team '%s'...\n", name, rs.GetLekkoTeam()) - if err := confirmInput(name); err != nil { - return err + if !isQuiet { + fmt.Printf("Deleting api key '%s' in team '%s'...\n", name, rs.GetLekkoTeam()) + } + if !isForce { + if err := confirmInput(name); err != nil { + return err + } + } + if !isDryRun { + if err := a.Delete(cmd.Context(), name); err != nil { + return err + } } - if err := a.Delete(cmd.Context(), name); err != nil { - return err + if !isQuiet { + printLinef(cmd, "Deleted '%s' api key.\n", name) + } else { + printLinef(cmd, "%s", name) } - fmt.Printf("Deleted api key.\n") return nil }, } - cmd.Flags().StringVarP(&name, "name", "n", "", "Name of api key to delete") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().BoolVarP(&isForce, ForceFlag, ForceFlagShort, ForceFlagDVal, ForceFlagDescription) return cmd } diff --git a/cmd/lekko/auth.go b/cmd/lekko/auth.go index b0384949..7ec3d318 100644 --- a/cmd/lekko/auth.go +++ b/cmd/lekko/auth.go @@ -30,7 +30,7 @@ import ( func authCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "authenticates lekko cli", + Short: "Authenticates lekko cli", } cmd.AddCommand(confirmUserCmd()) @@ -49,7 +49,7 @@ func authCmd() *cobra.Command { func loginCmd() *cobra.Command { return &cobra.Command{ Use: "login", - Short: "authenticate with lekko and github, if unauthenticated", + Short: "Authenticate with lekko and github, if unauthenticated", RunE: func(cmd *cobra.Command, args []string) error { return secrets.WithWriteSecrets(func(ws secrets.WriteSecrets) error { auth := oauth.NewOAuth(lekko.NewBFFClient(ws)) diff --git a/cmd/lekko/commit.go b/cmd/lekko/commit.go new file mode 100644 index 00000000..5370af6f --- /dev/null +++ b/cmd/lekko/commit.go @@ -0,0 +1,69 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func commitCmd() *cobra.Command { + var message, hash string + var isQuiet, isVerify bool + cmd := &cobra.Command{ + Short: "Commits local changes to the remote branch", + Use: formCmdUse("commit"), + DisableFlagsInUseLine: true, + //Use: "commit" + FlagOptions, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) + r, err := repo.NewLocal(wd, rs) + if err != nil { + return errors.Wrap(err, "new repo") + } + ctx := cmd.Context() + + if isQuiet { + r.ConfigureLogger(nil) + } + + if isVerify { + if _, err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { + return errors.Wrap(err, "verify") + } + } + + if hash, err = r.Commit(ctx, rs, message); err != nil { + return err + } + if isQuiet { + printLinef(cmd, "%s", hash) + } + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isVerify, "verify", "v", false, "verify changes before committing") + cmd.Flags().StringVarP(&message, "message", "m", "config change commit", "commit message") + return cmd +} diff --git a/cmd/lekko/compile.go b/cmd/lekko/compile.go new file mode 100644 index 00000000..653bc304 --- /dev/null +++ b/cmd/lekko/compile.go @@ -0,0 +1,105 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/lekkodev/cli/pkg/feature" + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +const ( + UpgradeFlag = "upgrade" + UpgradeFlagShort = "u" + UpgradeFlagDVal = false +) + +func compileCmd() *cobra.Command { + var isDryRun, isQuiet, isForce, isUpgrade, isVerbose bool + cmd := &cobra.Command{ + Short: "Compiles features based on individual definitions", + Use: formCmdUse("compile", "[namespace[/feature]]"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail() + + r, err := repo.NewLocal(wd, rs) + if err != nil { + return err + } + if isQuiet { + r.ConfigureLogger(nil) + } + ctx := cmd.Context() + rootMD, _, err := r.ParseMetadata(ctx) + if err != nil { + return errors.Wrap(err, "parse metadata") + } + + registry, err := r.ReBuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory, rootMD.UseExternalTypes) + if err != nil { + return errors.Wrap(err, "rebuild type registry") + } + var ns, f string + if len(args) > 0 { + ns, f, err = feature.ParseFeaturePath(args[0]) + if err != nil { + return err + } + } + if result, err := r.Compile(ctx, &repo.CompileRequest{ + Registry: registry, + NamespaceFilter: ns, + FeatureFilter: f, + DryRun: isDryRun, + IgnoreBackwardsCompatibility: isForce, + // don't verify file structure, since we may have not yet generated + // the DSLs for newly added Flags(). + Verify: false, + Upgrade: isUpgrade, + Verbose: isVerbose, + }); err != nil { + return errors.Wrap(err, "compile") + } else { + if !isQuiet { + printLinef(cmd, "Compilation completed\n") + } else { + s := "" + for _, r := range result { + s += fmt.Sprintf("%s/%s ", r.NamespaceName, r.FeatureName) + } + printLinef(cmd, "%s", strings.TrimSpace(s)) + } + } + return nil + }, + } + cmd.Flags().BoolVarP(&isForce, ForceFlag, ForceFlagShort, ForceFlagDVal, "force compilation, ignoring validation check failures") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, "skip persisting any newly compiled changes to disk") + cmd.Flags().BoolVarP(&isUpgrade, UpgradeFlag, UpgradeFlagShort, UpgradeFlagDVal, "upgrade any of the requested namespaces that are behind the latest version") + cmd.Flags().BoolVarP(&isVerbose, VerboseFlag, VerboseFlagShort, VerboseFlagDVal, "enable verbose error logging") + return cmd +} diff --git a/cmd/lekko/exp.go b/cmd/lekko/exp.go new file mode 100644 index 00000000..9fc0b51e --- /dev/null +++ b/cmd/lekko/exp.go @@ -0,0 +1,207 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/lekkodev/cli/pkg/feature" + "github.com/lekkodev/cli/pkg/logging" + "github.com/lekkodev/cli/pkg/star/static" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func expCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "exp", + Short: "Experimental commands", + } + + cmd.AddCommand( + expParseCmd(), + expCleanupCmd(), + expFormatCmd(), + ) + + return cmd +} + +func expFormatCmd() *cobra.Command { + var isQuiet, isVerbose bool + cmd := &cobra.Command{ + Short: "Format star files", + Use: formCmdUse("format"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail() + r, err := repo.NewLocal(wd, rs) + if err != nil { + return errors.Wrap(err, "format") + } + if isQuiet { + r.ConfigureLogger(nil) + } + + if ffs, err := r.Format(cmd.Context(), isVerbose); err != nil { + return err + } else if isQuiet { + printLinef(cmd, "%s", ffs) + } + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd +} +func expParseCmd() *cobra.Command { + var ns, featureName string + var isQuiet, all, printFeature bool + cmd := &cobra.Command{ + Short: "Parse a feature file using static analysis, and rewrite the starlark", + Use: formCmdUse("parse", "[namespace/feature]"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) + if err != nil { + return errors.Wrap(err, "new repo") + } + if isQuiet { + r.ConfigureLogger(nil) + } + ctx := cmd.Context() + rootMD, _, err := r.ParseMetadata(ctx) + if err != nil { + return errors.Wrap(err, "parse metadata") + } + registry, err := r.BuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory) + if err != nil { + return errors.Wrap(err, "build dynamic type registry") + } + var nsfs namespaceFeatures + if all { + nsfs, err = getNamespaceFeatures(ctx, r, ns, featureName) + if err != nil { + return err + } + } else { + if len(args) == 0 && !IsInteractive { + return errors.New("namespace/feature are required arguments in the non-interactive mode") + } else if len(args) > 1 { + return errors.New("wrong number of args - expected one in the form: [namespace[/feature]]") + } + + if len(args) == 1 { + ns, featureName, err = feature.ParseFeaturePath(args[0]) + if err != nil { + return err + } + nsfs, err = getNamespaceFeatures(ctx, r, ns, featureName) + if err != nil { + return err + } + } + + if len(ns) == 0 || len(featureName) == 0 { + nsf, err := featureSelect(ctx, r, ns, featureName) + if err != nil { + return err + } + nsfs = append(nsfs, nsf) + } + } + if !isQuiet { + for _, nsf := range nsfs { + f, err := r.Parse(ctx, nsf.namespace(), nsf.feature(), registry) + printLinef(cmd, "%s", logging.Bold(fmt.Sprintf("[%s]", nsf.String()))) + if errors.Is(err, static.ErrUnsupportedStaticParsing) { + fmt.Printf(" Unsupported static parsing: %v\n", err.Error()) + } else if err != nil { + printErr(cmd, err) + } else { + fmt.Printf("[%s] Parsed\n", f.Type) + if printFeature { + printLinef(cmd, "%s\n", protojson.MarshalOptions{ + Resolver: registry, + Multiline: true, + }.Format(f)) + } + } + } + } else { + s := "" + for _, nsf := range nsfs { + _, err := r.Parse(ctx, nsf.namespace(), nsf.feature(), registry) + s += fmt.Sprintf("%s/%s ", nsf.ns, nsf.featureName) + if errors.Is(err, static.ErrUnsupportedStaticParsing) { + fmt.Printf(" Unsupported static parsing: %v\n", err.Error()) + } else if err != nil { + printErr(cmd, err) + } + } + printLinef(cmd, "%s", strings.TrimSpace(s)) + } + return nil + }, + } + cmd.Flags().BoolVarP(&all, "all", "a", false, "parse all features") + cmd.Flags().BoolVarP(&printFeature, "print", "p", false, "print parsed feature(s)") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd +} +func expCleanupCmd() *cobra.Command { + var cmd = &cobra.Command{ + Short: "Deletes the current local branch or the branch specified (and its remote counterpart)", + Use: formCmdUse("cleanup", "[branchname]"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) + r, err := repo.NewLocal(wd, rs) + if err != nil { + return errors.Wrap(err, "cleanup") + } + var optionalBranchName *string + if len(args) > 0 { + optionalBranchName = &args[0] + } + if err = r.Cleanup(cmd.Context(), optionalBranchName, rs); err != nil { + return err + } + if optionalBranchName != nil { + printLinef(cmd, "%s", *optionalBranchName) + } + return nil + }, + } + return cmd +} diff --git a/cmd/lekko/feature.go b/cmd/lekko/feature.go index 364da3b4..33565bc5 100644 --- a/cmd/lekko/feature.go +++ b/cmd/lekko/feature.go @@ -20,12 +20,12 @@ import ( "fmt" "os" "strings" - "text/tabwriter" + + "github.com/lekkodev/cli/pkg/metadata" "github.com/AlecAivazis/survey/v2" "github.com/lekkodev/cli/pkg/feature" "github.com/lekkodev/cli/pkg/logging" - "github.com/lekkodev/cli/pkg/metadata" "github.com/lekkodev/cli/pkg/repo" "github.com/lekkodev/cli/pkg/secrets" "github.com/pkg/errors" @@ -36,23 +36,28 @@ import ( func featureCmd() *cobra.Command { cmd := &cobra.Command{ Use: "feature", - Short: "feature management", + Short: "Feature management", } cmd.AddCommand( - featureList(), - featureAdd(), - featureRemove(), + featureListCmd(), + featureAddCmd(), + featureRemoveCmd(), featureEval(), ) return cmd } -func featureList() *cobra.Command { +func featureListCmd() *cobra.Command { + var isQuiet bool var ns string cmd := &cobra.Command{ - Use: "list", - Short: "list all features", + Short: "List all features", + Use: formCmdUse("list", "[namespace]"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"namespace"}, args); err != nil { + return err + } wd, err := os.Getwd() if err != nil { return err @@ -61,39 +66,60 @@ func featureList() *cobra.Command { if err != nil { return err } + if len(args) == 1 { + ns = args[0] + } + nss, err := r.ListNamespaces(cmd.Context()) if err != nil { return errors.Wrap(err, "list namespaces") } if len(ns) > 0 { + found := false for _, namespaceMD := range nss { if namespaceMD.Name == ns { nss = []*metadata.NamespaceConfigRepoMetadata{namespaceMD} + found = true break } } + if !found { + return fmt.Errorf("namespace %s not found", ns) + } } + + s := "" for _, namespaceMD := range nss { ffs, err := r.GetFeatureFiles(cmd.Context(), namespaceMD.Name) if err != nil { return errors.Wrapf(err, "get feature files for ns %s", namespaceMD.Name) } for _, ff := range ffs { - fmt.Printf("%s/%s\n", ff.NamespaceName, ff.Name) + if !isQuiet { + s += fmt.Sprintf("%s/%s\n", ff.NamespaceName, ff.Name) + } else { + s += fmt.Sprintf("%s/%s ", ff.NamespaceName, ff.Name) + } } } + if isQuiet { + s = strings.TrimSpace(s) + } + printLinef(cmd, s) return nil }, } - cmd.Flags().StringVarP(&ns, "namespace", "n", "", "name of namespace to filter by") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) return cmd } -func featureAdd() *cobra.Command { - var ns, featureName, fType, fProtoMessage string +func featureAddCmd() *cobra.Command { + var isDryRun, isQuiet, isCompile bool + var ns, featureName, fType, path, fProtoMessage string cmd := &cobra.Command{ - Use: "add", - Short: "add feature", + Short: "Add feature, requires namespace/feature_name and the new feature type", + Use: formCmdUse("add", "namespace/feature", "type"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { wd, err := os.Getwd() if err != nil { @@ -103,6 +129,35 @@ func featureAdd() *cobra.Command { if err != nil { return err } + + rArgs, n := getNArgs(2, args) + + if !IsInteractive && n != 2 { + return errors.New("New feature namespace/name and type are required arguments") + } + + sArgs, err := splitStrIntoFixedSlice(rArgs[0], "/", 2) + if err != nil && !IsInteractive { + return errors.Wrap(err, "wrong format for the namespace/feature arguments") + } + if len(sArgs) > 0 { + ns = sArgs[0] + } else if !IsInteractive { + return errors.Wrap(err, "namespace cannot be empty") + } + + if len(sArgs) > 1 { + featureName = sArgs[1] + } else if !IsInteractive { + return errors.Wrap(err, "feature name cannot be empty") + } + + if n > 1 { + fType = rArgs[1] + } else if !IsInteractive { + return errors.Wrap(err, "feature type cannot be empty") + } + if len(ns) == 0 { nss, err := r.ListNamespaces(cmd.Context()) if err != nil { @@ -113,7 +168,7 @@ func featureAdd() *cobra.Command { options = append(options, ns.Name) } if err := survey.AskOne(&survey.Select{ - Message: "Namespace:", + Message: "*Namespace:", Options: options, }, &ns); err != nil { return errors.Wrap(err, "prompt") @@ -135,7 +190,7 @@ func featureAdd() *cobra.Command { } } - if fType == string(feature.FeatureTypeProto) && len(fProtoMessage) == 0 { + if fType == string(feature.FeatureTypeProto) && len(fProtoMessage) == 0 && IsInteractive { protos, err := r.GetProtoMessages(cmd.Context()) if err != nil { return errors.Wrap(err, "unable to get proto messages") @@ -148,29 +203,54 @@ func featureAdd() *cobra.Command { } } - ctx := cmd.Context() - if path, err := r.AddFeature(ctx, ns, featureName, feature.FeatureType(fType), fProtoMessage); err != nil { - return errors.Wrap(err, "add feature") + if !isDryRun { + if path, err = r.AddFeature(cmd.Context(), ns, featureName, feature.FeatureType(fType), fProtoMessage); err != nil { + return errors.Wrap(err, "add feature") + } + } else { + path = ns + "/" + featureName + ".star" + } + + if !isQuiet { + printLinef(cmd, "Added feature %s/%s at path %s\n", ns, featureName, path) } else { - fmt.Printf("Successfully added feature %s/%s at path %s\n", ns, featureName, path) - fmt.Printf("Make any changes you wish, and then run `lekko compile`.") + printLinef(cmd, "%s/%s", ns, featureName) + } + if !isQuiet && !isDryRun && !isCompile { + fmt.Printf("Modify the feature, and then run `lekko compile`\n") + } + + if isCompile { + if isQuiet { + r.ConfigureLogger(nil) + } + if _, err := r.Compile(cmd.Context(), &repo.CompileRequest{ + NamespaceFilter: ns, + FeatureFilter: featureName, + DryRun: isDryRun, + // don't verify file structure, since we may have not yet generated + // the DSLs for newly added Flags(). + }); err != nil { + return errors.Wrap(err, "compile") + } } - _, err = r.Compile(ctx, &repo.CompileRequest{}) return err }, } - cmd.Flags().StringVarP(&ns, "namespace", "n", "", "namespace to add feature in") - cmd.Flags().StringVarP(&featureName, "feature", "f", "", "name of feature to add") - cmd.Flags().StringVarP(&fType, "type", "t", "", "type of feature to create") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) cmd.Flags().StringVarP(&fProtoMessage, "proto-message", "m", "", "protobuf message of feature to create") + cmd.Flags().BoolVarP(&isCompile, "compile", "c", false, "compile feature after creation") return cmd } -func featureRemove() *cobra.Command { - var ns, featureName string +func featureRemoveCmd() *cobra.Command { + var isDryRun, isQuiet, isForce bool + var ns, featureName, nsFeature string cmd := &cobra.Command{ - Use: "remove", - Short: "remove feature", + Short: "Remove feature", + Use: formCmdUse("remove", "namespace/feature"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { wd, err := os.Getwd() if err != nil { @@ -180,35 +260,74 @@ func featureRemove() *cobra.Command { if err != nil { return err } - nsf, err := featureSelect(cmd.Context(), r, ns, featureName) - if err != nil { - return err + + rArgs, n := getNArgs(1, args) + if n == 1 { + _, err := splitStrIntoFixedSlice(rArgs[0], "/", 2) + if err != nil { + return errors.Wrap(err, "wrong format for the namespace/feature arguments") + } + + nsFeature = rArgs[0] + nsfs, err := getNamespaceFeatures(cmd.Context(), r, "", "") + if err != nil { + return err + } + found := false + for _, nsf := range nsfs { + if nsFeature == nsf.String() { + ns = nsf.namespace() + featureName = nsf.featureName + found = true + break + } + } + if !found { + return fmt.Errorf("the %s does not exist", nsFeature) + } + } else { + nsf, err := featureSelect(cmd.Context(), r, ns, featureName) + if err != nil { + return err + } + ns, featureName = nsf.namespace(), nsf.feature() } - ns, featureName = nsf.namespace(), nsf.feature() - // Confirm - featurePair := fmt.Sprintf("%s/%s", ns, featureName) - fmt.Printf("Deleting feature %s...\n", featurePair) - if err := confirmInput(featurePair); err != nil { - return err + + if !isForce { + featurePair := fmt.Sprintf("%s/%s", ns, featureName) + fmt.Printf("Deleting feature %s...\n", featurePair) + if err := confirmInput(featurePair); err != nil { + return err + } } - if err := r.RemoveFeature(cmd.Context(), ns, featureName); err != nil { - return errors.Wrap(err, "remove feature") + + if !isDryRun { + if err := r.RemoveFeature(cmd.Context(), ns, featureName); err != nil { + return errors.Wrap(err, "remove feature") + } + } + + if !isQuiet { + printLinef(cmd, "Removed feature %s/%s\n", ns, featureName) + } else { + printLinef(cmd, "%s/%s", ns, featureName) } - fmt.Printf("Successfully removed feature %s/%s\n", ns, featureName) return nil }, } - cmd.Flags().StringVarP(&ns, "namespace", "n", "", "namespace to remove feature from") - cmd.Flags().StringVarP(&featureName, "feature", "f", "", "name of feature to remove") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().BoolVarP(&isForce, ForceFlag, ForceFlagShort, ForceFlagDVal, ForceFlagDescription) return cmd } func featureEval() *cobra.Command { - var ns, featureName, jsonContext string - var verbose bool + var ns, featureName, nsFeature, jsonContext string + var isQuiet, isVerbose bool cmd := &cobra.Command{ - Use: "eval", - Short: "evaluate feature", + Short: "Evaluate feature", + Use: formCmdUse("eval", "namespace/feature", "context"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { wd, err := os.Getwd() if err != nil { @@ -219,6 +338,48 @@ func featureEval() *cobra.Command { return err } ctx := cmd.Context() + rArgs, _ := getNArgs(2, args) + + jsonContext = rArgs[1] + sArgs, err := splitStrIntoFixedSlice(rArgs[0], "/", 2) + if err != nil { + if !IsInteractive { + return errors.Wrap(err, "wrong format for the namespace/feature arguments") + } else { + ns = "" + featureName = "" + } + } else { + ns = sArgs[0] + featureName = sArgs[1] + } + + nsFeature = rArgs[0] + + found := false + nsfs, err := getNamespaceFeatures(cmd.Context(), r, "", "") + if err != nil { + return err + } + for _, nsf := range nsfs { + if nsFeature == nsf.String() { + ns = nsf.namespace() + featureName = nsf.featureName + found = true + break + } + } + if !found { + if !IsInteractive { + return fmt.Errorf("the %s does not exist", nsFeature) + } else { + //-- reset input + fmt.Printf("\nns:%s %s\n", ns, featureName) + ns = "" + featureName = "" + } + } + nsf, err := featureSelect(ctx, r, ns, featureName) if err != nil { return err @@ -239,8 +400,10 @@ func featureEval() *cobra.Command { if err := json.Unmarshal([]byte(jsonContext), &featureCtx); err != nil { return err } - fmt.Printf("Evaluating %s with context %s\n", logging.Bold(fmt.Sprintf("%s/%s", ns, featureName)), logging.Bold(jsonContext)) - fmt.Printf("-------------------\n") + if !isQuiet { + fmt.Printf("Evaluating %s with context %s\n", logging.Bold(fmt.Sprintf("%s/%s", ns, featureName)), logging.Bold(jsonContext)) + fmt.Printf("-------------------\n") + } anyVal, fType, path, err := r.Eval(ctx, ns, featureName, featureCtx) if err != nil { return err @@ -275,166 +438,22 @@ func featureEval() *cobra.Command { res = string(jsonRes) } - fmt.Printf("[%s] %s\n", fType, logging.Bold(res)) - if verbose { - fmt.Printf("[path] %v\n", path) - } - - return nil - }, - } - cmd.Flags().StringVarP(&ns, "namespace", "n", "", "namespace to remove feature from") - cmd.Flags().StringVarP(&featureName, "feature", "f", "", "name of feature to remove") - cmd.Flags().StringVarP(&jsonContext, "context", "c", "", "context to evaluate with in json format") - cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "print verbose evaluation information") - return cmd -} - -func namespaceCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "ns", - Short: "namespace management", - } - cmd.AddCommand( - nsList, - nsAdd(), - nsRemove(), - ) - return cmd -} - -var nsList = &cobra.Command{ - Use: "list", - Short: "list namespaces in the current repository", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) - if err != nil { - return err - } - nss, err := r.ListNamespaces(cmd.Context()) - if err != nil { - return err - } - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - fmt.Fprintf(w, "Namespace\tVersion\n") - for _, ns := range nss { - fmt.Fprintf(w, "%s\t%s\n", ns.Name, ns.Version) - } - w.Flush() - return nil - }, -} - -func nsAdd() *cobra.Command { - var name string - cmd := &cobra.Command{ - Use: "add", - Short: "add namespace", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) - if err != nil { - return err - } - if len(name) == 0 { - if err := survey.AskOne(&survey.Input{ - Message: "Namespace name:", - }, &name); err != nil { - return errors.Wrap(err, "prompt") - } - } - if err := r.AddNamespace(cmd.Context(), name); err != nil { - return errors.Wrap(err, "add namespace") - } - fmt.Printf("Successfully added namespace %s\n", name) - return nil - }, - } - cmd.Flags().StringVarP(&name, "name", "n", "", "name of namespace to delete") - return cmd -} - -func nsRemove() *cobra.Command { - var name string - cmd := &cobra.Command{ - Use: "remove", - Short: "remove namespace", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) - if err != nil { - return err - } - nss, err := r.ListNamespaces(cmd.Context()) - if err != nil { - return err - } - if len(name) == 0 { - var options []string - for _, ns := range nss { - options = append(options, ns.Name) - } - if err := survey.AskOne(&survey.Select{ - Message: "Select namespace to remove:", - Options: options, - }, &name); err != nil { - return errors.Wrap(err, "prompt") + if !isQuiet { + printLinef(cmd, "[%s] %s\n", fType, logging.Bold(res)) + if isVerbose { + fmt.Printf("[path] %v\n", path) } } else { - // let's verify that the input namespace actually exists - var exists bool - for _, ns := range nss { - if name == ns.Name { - exists = true - break - } - } - if !exists { - return errors.Errorf("Namespace %s does not exist", name) - } - } - // Confirm deletion - fmt.Printf("Deleting namespace %s...\n", name) - if err := confirmInput(name); err != nil { - return err - } - // actually delete - if err := r.RemoveNamespace(cmd.Context(), name); err != nil { - return errors.Wrap(err, "remove namespace") + printLinef(cmd, "%v", res) } - fmt.Printf("Successfully deleted namespace %s\n", name) return nil }, } - cmd.Flags().StringVarP(&name, "name", "n", "", "name of namespace to delete") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isVerbose, VerboseFlag, VerboseFlagShort, VerboseFlagDVal, VerboseFlagDescription) return cmd } -// Helpful method to ask the user to enter a piece of text before -// doing something irreversible, like deleting something. -func confirmInput(text string) error { - var inputText string - if err := survey.AskOne(&survey.Input{ - Message: fmt.Sprintf("Enter '%s' to continue:", text), - }, &inputText); err != nil { - return errors.Wrap(err, "prompt") - } - if text != inputText { - return errors.New("incorrect input") - } - return nil -} - type namespaceFeature struct { ns, featureName string } diff --git a/cmd/lekko/flags.go b/cmd/lekko/flags.go new file mode 100644 index 00000000..51da3f30 --- /dev/null +++ b/cmd/lekko/flags.go @@ -0,0 +1,66 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +const ( + QuietModeFlag = "quiet-mode" + QuietModeFlagShort = "q" + QuietModeFlagDVal = false + + DryRunFlag = "dry-run" + DryRunFlagShort = "d" + DryRunFlagDVal = false + + ForceFlag = "force" + ForceFlagShort = "f" + ForceFlagDVal = false + + InteractiveModeFlag = "interactive" + InteractiveModeFlagShort = "i" + + VerboseFlag = "verbose" + VerboseFlagShort = "v" + + VerboseFlagDVal = false + + LekkoBackendFlag = "backend-url" + LekkoBackendFlagDescription = "Lekko backend url" + LekkoBackendFlagURL = "https://prod.api.lekko.dev" + IsLekkoBackendFlagHidden = true +) +const ( + FlagOptions = "[FLAGS]" +) + +var InteractiveModeFlagDVal = true +var IsInteractive = true +var QuietModeFlagDescription string = "prints without formatting or extra information" +var DryRunFlagDescription string = "does not persist results of the executed command" +var ForceFlagDescription string = "forces all user confirmation evaluations to true" +var InteractiveModeFlagDescription string = "supports functionality for interactive terminals" +var VerboseFlagDescription = "verbose output" diff --git a/cmd/lekko/main.go b/cmd/lekko/main.go index 54b91388..9d123944 100644 --- a/cmd/lekko/main.go +++ b/cmd/lekko/main.go @@ -15,31 +15,11 @@ package main import ( - "bytes" "context" - "fmt" - "io" "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - bffv1beta1 "buf.build/gen/go/lekkodev/cli/protocolbuffers/go/lekko/bff/v1beta1" - "github.com/AlecAivazis/survey/v2" - "github.com/bufbuild/connect-go" - "github.com/lekkodev/cli/pkg/feature" - "github.com/lekkodev/cli/pkg/gh" - "github.com/lekkodev/cli/pkg/k8s" "github.com/lekkodev/cli/pkg/lekko" "github.com/lekkodev/cli/pkg/logging" - "github.com/lekkodev/cli/pkg/repo" - "github.com/lekkodev/cli/pkg/secrets" - "github.com/lekkodev/cli/pkg/star/static" - "github.com/mitchellh/go-homedir" - "github.com/pkg/errors" - "google.golang.org/protobuf/encoding/protojson" - "github.com/spf13/cobra" ) @@ -52,29 +32,22 @@ func main() { rootCmd.AddCommand(verifyCmd()) rootCmd.AddCommand(commitCmd()) rootCmd.AddCommand(reviewCmd()) - rootCmd.AddCommand(mergeCmd) + rootCmd.AddCommand(mergeCmd()) rootCmd.AddCommand(restoreCmd()) rootCmd.AddCommand(teamCmd()) rootCmd.AddCommand(repoCmd()) rootCmd.AddCommand(featureCmd()) rootCmd.AddCommand(namespaceCmd()) rootCmd.AddCommand(apikeyCmd()) - rootCmd.AddCommand(upgradeCmd()) - // auth rootCmd.AddCommand(authCmd()) - // exp - k8sCmd.AddCommand(applyCmd()) - k8sCmd.AddCommand(listCmd()) - experimentalCmd.AddCommand(k8sCmd) - experimentalCmd.AddCommand(parseCmd()) - experimentalCmd.AddCommand(cleanupCmd) - experimentalCmd.AddCommand(formatCmd()) - rootCmd.AddCommand(experimentalCmd) + rootCmd.AddCommand(expCmd()) + //rootCmd.AddCommand(generateCmd()) - logging.InitColors() if err := rootCmd.ExecuteContext(context.Background()); err != nil { - fmt.Println(err) + printErr(rootCmd, err) os.Exit(1) + } else { + os.Exit(0) } } @@ -85,593 +58,30 @@ func rootCmd() *cobra.Command { Version: version, SilenceUsage: true, SilenceErrors: true, - } - cmd.PersistentFlags().StringVar(&lekko.URL, "backend-url", "https://prod.api.lekko.dev", "Lekko backend url") - return cmd -} - -func formatCmd() *cobra.Command { - var verbose bool - cmd := &cobra.Command{ - Use: "format", - Short: "format star files", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail() - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - return r.Format(cmd.Context(), verbose) - }, - } - cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") - return cmd -} - -func compileCmd() *cobra.Command { - var force, dryRun, upgrade, verbose bool - cmd := &cobra.Command{ - Use: "compile [namespace[/feature]]", - Short: "compiles features based on individual definitions", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail() - r, err := repo.NewLocal(wd, rs) - if err != nil { - return err - } - ctx := cmd.Context() - rootMD, _, err := r.ParseMetadata(ctx) - if err != nil { - return errors.Wrap(err, "parse metadata") - } - registry, err := r.ReBuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory, rootMD.UseExternalTypes) - if err != nil { - return errors.Wrap(err, "rebuild type registry") - } - var ns, f string - if len(args) > 0 { - ns, f, err = feature.ParseFeaturePath(args[0]) - if err != nil { - return err - } - } - if _, err := r.Compile(ctx, &repo.CompileRequest{ - Registry: registry, - NamespaceFilter: ns, - FeatureFilter: f, - DryRun: dryRun, - IgnoreBackwardsCompatibility: force, - // don't verify file structure, since we may have not yet generated - // the DSLs for newly added features. - Verify: false, - Upgrade: upgrade, - Verbose: verbose, - }); err != nil { - return errors.Wrap(err, "compile") - } - return nil - }, - } - cmd.Flags().BoolVarP(&force, "force", "f", false, "force compilation, ignoring validation check failures.") - cmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "skip persisting any newly compiled changes to disk.") - cmd.Flags().BoolVarP(&upgrade, "upgrade", "u", false, "upgrade any of the requested namespaces that are behind the latest version.") - cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose error logging.") - return cmd -} - -func verifyCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "verify [namespace[/feature]]", - Short: "verifies features based on individual definitions", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail() - r, err := repo.NewLocal(wd, rs) - if err != nil { - return err - } - ctx := cmd.Context() - rootMD, _, err := r.ParseMetadata(ctx) - if err != nil { - return errors.Wrap(err, "parse metadata") - } - registry, err := r.ReBuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory, rootMD.UseExternalTypes) - if err != nil { - return errors.Wrap(err, "rebuild type registry") - } - var ns, f string - if len(args) > 0 { - ns, f, err = feature.ParseFeaturePath(args[0]) - if err != nil { - return err - } - } - return r.Verify(ctx, &repo.VerifyRequest{ - Registry: registry, - NamespaceFilter: ns, - FeatureFilter: f, - }) - }, - } - return cmd -} - -func parseCmd() *cobra.Command { - var ns, featureName string - var all, printFeature bool - cmd := &cobra.Command{ - Use: "parse", - Short: "parse a feature file using static analysis, and rewrite the starlark", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) - if err != nil { - return errors.Wrap(err, "new repo") - } - ctx := cmd.Context() - rootMD, _, err := r.ParseMetadata(ctx) - if err != nil { - return errors.Wrap(err, "parse metadata") - } - registry, err := r.BuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory) - if err != nil { - return errors.Wrap(err, "build dynamic type registry") - } - var nsfs namespaceFeatures - if all { - nsfs, err = getNamespaceFeatures(ctx, r, ns, featureName) - if err != nil { - return err - } - } else { - nsf, err := featureSelect(ctx, r, ns, featureName) - if err != nil { - return err + PersistentPreRun: func(cmd *cobra.Command, args []string) { + logging.InitColors(IsInteractive) + //-- checking passed pos args + s := getUseCmdParams(cmd.UseLine(), cmd.Name()) + if err := errIfMoreArgs(append(s.PosArgs, s.PosOptionalArgs...), args); err != nil { + printErr(cmd, err) + os.Exit(1) + } + if !IsInteractive { + if err := errIfLessArgs(s.PosArgs, args); err != nil { + printErr(cmd, err) + os.Exit(1) } - nsfs = append(nsfs, nsf) - } - for _, nsf := range nsfs { - f, err := r.Parse(ctx, nsf.namespace(), nsf.feature(), registry) - fmt.Print(logging.Bold(fmt.Sprintf("[%s]", nsf.String()))) - if errors.Is(err, static.ErrUnsupportedStaticParsing) { - fmt.Printf(" Unsupported static parsing: %v\n", err.Error()) - } else if err != nil { - fmt.Printf(" %v\n", err) - } else { - fmt.Printf("[%s] Parsed\n", f.Type) - if printFeature { - fmt.Println(protojson.MarshalOptions{ - Resolver: registry, - Multiline: true, - }.Format(f)) - } - } - } - return nil - }, - } - cmd.Flags().StringVarP(&ns, "namespace", "n", "", "namespace to remove feature from") - cmd.Flags().StringVarP(&featureName, "feature", "f", "", "name of feature to remove") - cmd.Flags().BoolVarP(&all, "all", "a", false, "parse all features") - cmd.Flags().BoolVarP(&printFeature, "print", "p", false, "print parsed feature(s)") - return cmd -} - -func reviewCmd() *cobra.Command { - var title string - cmd := &cobra.Command{ - Use: "review", - Short: "creates a pr with your changes", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - if err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { - return errors.Wrap(err, "verify") - } - - ghCli := gh.NewGithubClientFromToken(ctx, rs.GetGithubToken()) - if _, err := ghCli.GetUser(ctx); err != nil { - return errors.Wrap(err, "github auth fail") - } - - if len(title) == 0 { - fmt.Printf("-------------------\n") - if err := survey.AskOne(&survey.Input{ - Message: "Title:", - }, &title); err != nil { - return errors.Wrap(err, "prompt") - } - } - - _, err = r.Review(ctx, title, ghCli, rs) - return err - }, - } - cmd.Flags().StringVarP(&title, "title", "t", "", "Title of pull request") - return cmd -} - -var mergeCmd = &cobra.Command{ - Use: "merge [pr-number]", - Short: "merges a pr for the current branch", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - ctx := cmd.Context() - if err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { - return errors.Wrap(err, "verify") - } - var prNum *int - if len(args) > 0 { - num, err := strconv.Atoi(args[0]) - if err != nil { - return errors.Wrap(err, "pr-number arg") - } - prNum = &num - } - - ghCli := gh.NewGithubClientFromToken(ctx, rs.GetGithubToken()) - if _, err := ghCli.GetUser(ctx); err != nil { - return errors.Wrap(err, "github auth fail") - } - if err := r.Merge(ctx, prNum, ghCli, rs); err != nil { - return errors.Wrap(err, "merge") - } - fmt.Printf("PR merged.\n") - if len(rs.GetLekkoTeam()) > 0 { - u, err := r.GetRemoteURL() - if err != nil { - return errors.Wrap(err, "get remote url") - } - owner, repo, err := gh.ParseOwnerRepo(u) - if err != nil { - return errors.Wrap(err, "parse owner repo") - } - repos, err := lekko.NewBFFClient(rs).ListRepositories(ctx, connect.NewRequest(&bffv1beta1.ListRepositoriesRequest{})) - if err != nil { - return errors.Wrap(err, "repository fetch failed") - } - defaultBranch := "" - for _, r := range repos.Msg.GetRepositories() { - if r.OwnerName == owner && r.RepoName == repo { - defaultBranch = r.BranchName - } - } - if len(defaultBranch) == 0 { - return errors.New("repository not found when rolling out") - } - fmt.Printf("Visit %s to monitor your rollout.\n", rolloutsURL(rs.GetLekkoTeam(), owner, repo, defaultBranch)) - } - return nil - }, -} - -func rolloutsURL(team, owner, repo, branch string) string { - return fmt.Sprintf("https://app.lekko.com/teams/%s/repositories/%s/%s/branches/%s/commits", team, owner, repo, branch) -} - -type provider string - -const ( - providerLekko provider = "lekko" - providerGithub provider = "github" -) - -func (p *provider) String() string { - return string(*p) -} - -func (p *provider) Set(v string) error { - switch v { - case string(providerLekko), string(providerGithub): - *p = provider(v) - default: - return errors.New(`must be one of "lekko" or "github"`) - } - return nil -} - -func (p *provider) Type() string { - return "provider" -} - -var k8sCmd = &cobra.Command{ - Use: "k8s", - Short: "manage lekko configurations in kubernetes. Uses the current k8s context set in your kubeconfig file.", -} - -func localKubeParams(cmd *cobra.Command, kubeConfig *string) { - var defaultKubeconfig string - // ref: https://github.com/kubernetes/client-go/blob/master/examples/out-of-cluster-client-configuration/main.go - home, err := homedir.Dir() - if err == nil { - defaultKubeconfig = filepath.Join(home, ".kube", "config") - } - cmd.Flags().StringVarP(kubeConfig, "kubeconfig", "c", defaultKubeconfig, "absolute path to the kube config file") -} - -func applyCmd() *cobra.Command { - var kubeConfig string - ret := &cobra.Command{ - Use: "apply", - Short: "apply local configurations to kubernetes configmaps", - RunE: func(cmd *cobra.Command, args []string) error { - if err := cmd.ParseFlags(args); err != nil { - return errors.Wrap(err, "failed to parse flags") - } - - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - ctx := cmd.Context() - if err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { - return errors.Wrap(err, "verify") - } - kube, err := k8s.NewKubernetes(kubeConfig, r) - if err != nil { - return errors.Wrap(err, "failed to build k8s client") - } - if err := kube.Apply(ctx, rs.GetUsername()); err != nil { - return errors.Wrap(err, "apply") - } - - return nil - }, - } - localKubeParams(ret, &kubeConfig) - return ret -} - -func listCmd() *cobra.Command { - var kubeConfig string - ret := &cobra.Command{ - Use: "list", - Short: "list lekko configurations currently in kubernetes", - RunE: func(cmd *cobra.Command, args []string) error { - if err := cmd.ParseFlags(args); err != nil { - return errors.Wrap(err, "failed to parse flags") - } - - kube, err := k8s.NewKubernetes(kubeConfig, nil) - if err != nil { - return errors.Wrap(err, "failed to build k8s client") - } - if err := kube.List(cmd.Context()); err != nil { - return errors.Wrap(err, "list") - } - return nil - }, - } - localKubeParams(ret, &kubeConfig) - return ret -} - -var experimentalCmd = &cobra.Command{ - Use: "exp", - Short: "experimental commands", -} - -func commitCmd() *cobra.Command { - var message string - cmd := &cobra.Command{ - Use: "commit", - Short: "commits local changes to the remote branch", - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - ctx := cmd.Context() - if err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { - return errors.Wrap(err, "verify") } - if _, err = r.Commit(ctx, rs, message); err != nil { - return err - } - return nil }, } - cmd.Flags().StringVarP(&message, "message", "m", "config change commit", "commit message") - return cmd -} + cmd.PersistentFlags().BoolVarP(&IsInteractive, InteractiveModeFlag, InteractiveModeFlagShort, InteractiveModeFlagDVal, InteractiveModeFlagDescription) + cmd.CompletionOptions.DisableDefaultCmd = true + cmd.PersistentFlags().StringVar(&lekko.URL, LekkoBackendFlag, LekkoBackendFlagURL, LekkoBackendFlagDescription) -var cleanupCmd = &cobra.Command{ - Use: "cleanup [branchname]", - Short: "deletes the current local branch or the branch specified (and its remote counterpart)", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err + if IsLekkoBackendFlagHidden { + if err := cmd.PersistentFlags().MarkHidden(LekkoBackendFlag); err != nil { + printErrExit(cmd, err) } - rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - var optionalBranchName *string - if len(args) > 0 { - optionalBranchName = &args[0] - } - if err = r.Cleanup(cmd.Context(), optionalBranchName, rs); err != nil { - return err - } - return nil - }, -} - -func restoreCmd() *cobra.Command { - var force bool - cmd := &cobra.Command{ - Use: "restore [hash]", - Short: "restores repo to a particular hash", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) - r, err := repo.NewLocal(wd, rs) - if err != nil { - return errors.Wrap(err, "new repo") - } - if err := r.RestoreWorkingDirectory(args[0]); err != nil { - return errors.Wrap(err, "restore wd") - } - ctx := cmd.Context() - rootMD, _, err := r.ParseMetadata(ctx) - if err != nil { - return errors.Wrap(err, "parse metadata") - } - registry, err := r.ReBuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory, rootMD.UseExternalTypes) - if err != nil { - return errors.Wrap(err, "rebuild type registry") - } - fmt.Printf("Successfully rebuilt dynamic type registry.\n") - if _, err := r.Compile(ctx, &repo.CompileRequest{ - Registry: registry, - DryRun: false, - IgnoreBackwardsCompatibility: force, - }); err != nil { - return errors.Wrap(err, "compile") - } - fmt.Printf("Restored hash %s to your working directory. \nRun `lekko review` to create a PR with these changes.\n", args[0]) - return nil - }, - } - cmd.Flags().BoolVarP(&force, "force", "f", false, "force compilation, ignoring validation check failures.") - return cmd -} - -func upgradeCmd() *cobra.Command { - var apikey string - type execReq struct { - stdout, stderr io.Writer - env []string - verbose bool - } - execCmd := func(ctx context.Context, req *execReq, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - stdout, stderr := bytes.NewBuffer(nil), bytes.NewBuffer(nil) - if req.stdout != nil { - cmd.Stdout = req.stdout - } else { - cmd.Stdout = stdout - } - if req.stderr != nil { - cmd.Stderr = req.stderr - } else { - cmd.Stderr = stderr - } - cmd.Env = append(cmd.Env, req.env...) - if req.verbose { - fmt.Printf("Running '%s %s'...\n", name, strings.Join(args, " ")) - } - err := cmd.Run() - if err != nil { - return nil, errors.Wrapf(err, "%s %s", name, strings.Join(args, " ")) - } - return stdout.Bytes(), nil - } - checkToolExists := func(ctx context.Context, name string) error { - if _, err := execCmd(ctx, &execReq{}, name, "--version"); err != nil { - return errors.Wrapf(err, "command not found: '%s'", name) - } - return nil - } - cmd := &cobra.Command{ - Use: "upgrade", - Short: "upgrade lekko to the latest version using homebrew", - RunE: func(cmd *cobra.Command, args []string) error { - if len(apikey) == 0 { - return errors.New("no api key provided") - } - ctx := cmd.Context() - for _, tool := range []string{"brew", "curl", "jq"} { - if err := checkToolExists(ctx, tool); err != nil { - return err - } - } - if _, err := execCmd(ctx, &execReq{ - stdout: os.Stdout, - stderr: os.Stderr, - verbose: true, - }, "brew", "update"); err != nil { - return err - } - if _, err := execCmd(ctx, &execReq{ - stdout: os.Stdout, - stderr: os.Stderr, - verbose: true, - }, "brew", "tap", "lekkodev/lekko"); err != nil { - return err - } - brewRepoOutput, err := execCmd(ctx, &execReq{}, "brew", "--repo") - if err != nil { - return err - } - brewRepo := strings.TrimSpace(string(brewRepoOutput)) - tokenScript := fmt.Sprintf("%s/Library/Taps/lekkodev/homebrew-lekko/gen_token.sh", brewRepo) - tokenOutput, err := execCmd(ctx, &execReq{}, tokenScript) - if err != nil { - return err - } - token := strings.TrimSpace(string(tokenOutput)) - if len(token) == 0 { - return errors.New("failed to generate token") - } - envToSet := "HOMEBREW_GITHUB_API_TOKEN" - _, err = execCmd(ctx, &execReq{ - stdout: os.Stdout, - stderr: os.Stderr, - verbose: true, - env: []string{fmt.Sprintf("%s=%s", envToSet, token)}, - }, "brew", "upgrade", "lekko") - return err - }, } - cmd.Flags().StringVarP(&apikey, "apikey", "a", os.Getenv("LEKKO_APIKEY"), "apikey used to upgrade") return cmd } diff --git a/cmd/lekko/merge.go b/cmd/lekko/merge.go new file mode 100644 index 00000000..e5027d5c --- /dev/null +++ b/cmd/lekko/merge.go @@ -0,0 +1,110 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "strconv" + + bffv1beta1 "buf.build/gen/go/lekkodev/cli/protocolbuffers/go/lekko/bff/v1beta1" + "github.com/bufbuild/connect-go" + "github.com/lekkodev/cli/pkg/gh" + "github.com/lekkodev/cli/pkg/lekko" + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func mergeCmd() *cobra.Command { + var isQuiet bool + cmd := &cobra.Command{ + Short: "Merges a pr for the current branch", + Use: formCmdUse("merge", "[pr-number]"), + DisableFlagsInUseLine: true, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) + r, err := repo.NewLocal(wd, rs) + if isQuiet { + r.ConfigureLogger(nil) + } + if err != nil { + return errors.Wrap(err, "new repo") + } + ctx := cmd.Context() + if _, err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { + return errors.Wrap(err, "verify") + } + var prNum *int + if len(args) > 0 { + num, err := strconv.Atoi(args[0]) + if err != nil { + return errors.Wrap(err, "pr-number arg") + } + prNum = &num + } + ghCli := gh.NewGithubClientFromToken(ctx, rs.GetGithubToken()) + if _, err := ghCli.GetUser(ctx); err != nil { + return errors.Wrap(err, "github auth fail") + } + var prNumRet int + if prNumRet, err = r.Merge(ctx, prNum, ghCli, rs); err != nil { + return errors.Wrap(err, "merge") + } + if !isQuiet { + printLinef(cmd, "%d PR merged.\n", *prNum) + if len(rs.GetLekkoTeam()) > 0 { + u, err := r.GetRemoteURL() + if err != nil { + return errors.Wrap(err, "get remote url") + } + owner, repo, err := gh.ParseOwnerRepo(u) + if err != nil { + return errors.Wrap(err, "parse owner repo") + } + repos, err := lekko.NewBFFClient(rs).ListRepositories(ctx, connect.NewRequest(&bffv1beta1.ListRepositoriesRequest{})) + if err != nil { + return errors.Wrap(err, "repository fetch failed") + } + defaultBranch := "" + for _, r := range repos.Msg.GetRepositories() { + if r.OwnerName == owner && r.RepoName == repo { + defaultBranch = r.BranchName + } + } + if len(defaultBranch) == 0 { + return errors.New("repository not found when rolling out") + } + printLinef(cmd, "Visit %s to monitor your rollout.\n", rolloutsURL(rs.GetLekkoTeam(), owner, repo, defaultBranch)) + } + } else { + printLinef(cmd, "%d", prNumRet) + } + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd +} + +func rolloutsURL(team, owner, repo, branch string) string { + return fmt.Sprintf("https://app.lekko.com/teams/%s/repositories/%s/%s/branches/%s/commits", team, owner, repo, branch) +} diff --git a/cmd/lekko/ns.go b/cmd/lekko/ns.go new file mode 100644 index 00000000..465c1f09 --- /dev/null +++ b/cmd/lekko/ns.go @@ -0,0 +1,219 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/AlecAivazis/survey/v2" + "github.com/lekkodev/cli/pkg/metadata" + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +const ( + NsAddCmd = "add" + NsRmCmd = "remove" + NsLsCmd = "list" +) + +func namespaceCmd() *cobra.Command { + cmd := &cobra.Command{ + Short: "Namespace management", + Use: "ns command" + FlagOptions + "[args]", + } + cmd.AddCommand( + nsListCmd(), + nsAddCmd(), + nsRemoveCmd(), + ) + return cmd +} + +func nsListCmd() *cobra.Command { + cmd := &cobra.Command{ + Short: "List namespaces in the current repository", + Use: formCmdUse(NsLsCmd, FlagOptions), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) + if err != nil { + return err + } + nss, err := r.ListNamespaces(cmd.Context()) + if err != nil { + return err + } + nsPrint(cmd, nss) + return nil + }, + } + cmd.Flags().BoolP(QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd +} + +func nsAddCmd() *cobra.Command { + var isDryRun, isQuiet bool + var name string + cmd := &cobra.Command{ + Short: "Add namespace", + Use: formCmdUse(NsAddCmd, "namespace"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) + if err != nil { + return err + } + rArgs, n := getNArgs(1, args) + if n != 1 && !IsInteractive { + return errors.New("Namespace name is required.\n") + } + name = rArgs[0] + + if len(name) == 0 { + if err := survey.AskOne(&survey.Input{ + Message: "Namespace name:", + }, &name); err != nil { + return errors.Wrap(err, "prompt") + } + } + + if !isDryRun { + if err := r.AddNamespace(cmd.Context(), name); err != nil { + return errors.Wrap(err, "add namespace") + } + } + + if !isQuiet { + printLinef(cmd, "Added namespace %s\n", name) + } else { + printLinef(cmd, "%s", name) + } + + return nil + }, + } + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd +} + +func nsRemoveCmd() *cobra.Command { + var isDryRun, isQuiet, isForce bool + var name string + cmd := &cobra.Command{ + Short: "Remove namespace", + Use: formCmdUse(NsRmCmd, "namespace"), + DisableFlagsInUseLine: true, + // Use: NsRmCmd + FlagOptions + "namespace", + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + r, err := repo.NewLocal(wd, secrets.NewSecretsOrFail()) + if err != nil { + return err + } + rArgs, n := getNArgs(1, args) + if n != 1 && !IsInteractive { + return errors.New("Namespace name is required.\n") + } + name = rArgs[0] + + nss, err := r.ListNamespaces(cmd.Context()) + if err != nil { + return err + } + if len(name) == 0 { + var options []string + for _, ns := range nss { + options = append(options, ns.Name) + } + if err := survey.AskOne(&survey.Select{ + Message: "Select namespace to remove:", + Options: options, + }, &name); err != nil { + return errors.Wrap(err, "prompt") + } + } else { + // let's verify that the input namespace actually exists + var exists bool + for _, ns := range nss { + if name == ns.Name { + exists = true + break + } + } + if !exists { + return errors.Errorf("Namespace %s does not exist", name) + } + } + + if !isForce { + fmt.Printf("Deleting namespace %s...\n", name) + if err := confirmInput(name); err != nil { + return err + } + } + + if !isDryRun { + if err := r.RemoveNamespace(cmd.Context(), name); err != nil { + return errors.Wrap(err, "remove namespace") + } + } + if !isQuiet { + printLinef(cmd, "Deleted namespace %s\n", name) + } else { + printLinef(cmd, "%s", name) + } + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().BoolVarP(&isForce, ForceFlag, ForceFlagShort, ForceFlagDVal, ForceFlagDescription) + return cmd +} + +func nsPrint(cmd *cobra.Command, nss []*metadata.NamespaceConfigRepoMetadata) { + if isQuiet, _ := cmd.Flags().GetBool(QuietModeFlag); isQuiet { + nsStr := "" + for _, ns := range nss { + nsStr += fmt.Sprintf("%s ", ns.Name) + } + printLinef(cmd, "%s", strings.TrimSpace(nsStr)) + } else { + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + _, _ = fmt.Fprintf(w, "Namespace\tVersion\n") + for _, ns := range nss { + _, _ = fmt.Fprintf(w, "%s\t%s\n", ns.Name, ns.Version) + } + _ = w.Flush() + } +} diff --git a/cmd/lekko/provider.go b/cmd/lekko/provider.go new file mode 100644 index 00000000..b9802ef1 --- /dev/null +++ b/cmd/lekko/provider.go @@ -0,0 +1,42 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "github.com/pkg/errors" + +type provider string + +const ( + providerLekko provider = "lekko" + providerGithub provider = "github" +) + +func (p *provider) String() string { + return string(*p) +} + +func (p *provider) Set(v string) error { + switch v { + case string(providerLekko), string(providerGithub): + *p = provider(v) + default: + return errors.New(`must be one of "lekko" or "github"`) + } + return nil +} + +func (p *provider) Type() string { + return "provider" +} diff --git a/cmd/lekko/repo.go b/cmd/lekko/repo.go index 042b420f..82cd2223 100644 --- a/cmd/lekko/repo.go +++ b/cmd/lekko/repo.go @@ -20,6 +20,7 @@ import ( "io" "os" "path" + "path/filepath" "strings" "github.com/AlecAivazis/survey/v2" @@ -36,13 +37,22 @@ const ( lekkoAppInstallURL string = "https://github.com/apps/lekko-app/installations/new" ) +const ( + DeleteOnGitHubFlag = "delete-on-github" + DeleteOnGitHubFlagShort = "x" + DeleteOnGitHubFlagDVal = false + DeleteOnGitHubFlagDescription = "deletes the repository on GitHub" +) + +var ForceFlagDescriptionLocal string = "forces all user confirmations to true, requires non-interactive mode" + func repoCmd() *cobra.Command { cmd := &cobra.Command{ Use: "repo", - Short: "repository management", + Short: "Repository management", } cmd.AddCommand( - repoListCmd, + repoListCmd(), repoCreateCmd(), repoCloneCmd(), repoDeleteCmd(), @@ -51,22 +61,31 @@ func repoCmd() *cobra.Command { return cmd } -var repoListCmd = &cobra.Command{ - Use: "list", - Short: "List the config repositories in the currently active team", - RunE: func(cmd *cobra.Command, args []string) error { - rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) - repo := repo.NewRepoCmd(lekko.NewBFFClient(rs)) - repos, err := repo.List(cmd.Context()) - if err != nil { - return err - } - fmt.Printf("%d repos found in team %s.\n", len(repos), rs.GetLekkoTeam()) - for _, r := range repos { - fmt.Printf("%s:\n\t%s\n\t%s\n", logging.Bold(fmt.Sprintf("[%s/%s]", r.Owner, r.RepoName)), r.Description, r.URL) - } - return nil - }, +func repoListCmd() *cobra.Command { + var isQuiet bool + cmd := &cobra.Command{ + Short: "List the config repositories in the currently active team", + Use: formCmdUse("list"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{}, args); err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) + repo := repo.NewRepoCmd(lekko.NewBFFClient(rs)) + repos, err := repo.List(cmd.Context()) + if err != nil { + return err + } + if !isQuiet { + fmt.Printf("%d repos found in team %s\n", len(repos), rs.GetLekkoTeam()) + } + printRepos(cmd, repos) + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd } func waitForEnter(r io.Reader) error { @@ -77,12 +96,27 @@ func waitForEnter(r io.Reader) error { func repoCreateCmd() *cobra.Command { var owner, repoName, description string + var isDryRun, isQuiet bool cmd := &cobra.Command{ - Use: "create", - Short: "Create a new, empty config repository in the currently active team", + Short: "Create a new, empty config repository in the currently active team", + Use: formCmdUse("create", "[owner/]repoName"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"[owner/]repoName"}, args); err != nil { + return err + } rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) - if len(owner) == 0 { + rArgs, _ := getNArgs(1, args) + + if strings.Contains(rArgs[0], "/") { + ownerRepoName := strings.Split(rArgs[0], "/") + owner = ownerRepoName[0] + repoName = ownerRepoName[1] + } else { + repoName = rArgs[0] + } + + if len(owner) == 0 && IsInteractive { if err := survey.AskOne(&survey.Input{ Message: "GitHub Owner:", Help: "Name of the GitHub organization the create the repository under. If left empty, defaults to personal account.", @@ -92,49 +126,87 @@ func repoCreateCmd() *cobra.Command { } if len(owner) == 0 { owner = rs.GetGithubUser() + if !IsInteractive && !isQuiet { + printLinef(cmd, "Owner defaulted to %s\n", owner) + } } + if len(repoName) == 0 { - if err := survey.AskOne(&survey.Input{ - Message: "Repo Name:", - }, &repoName); err != nil { - return errors.Wrap(err, "prompt") + if IsInteractive { + if err := survey.AskOne(&survey.Input{ + Message: "Repo Name:", + }, &repoName); err != nil { + return errors.Wrap(err, "prompt") + } + } else { + return errors.New("New repo argument is required in the form [owner/]repoName\n") } } + if len(description) == 0 { - if err := survey.AskOne(&survey.Input{ - Message: "Repo Description:", - Help: "Description for your new repository. If left empty, a default description message will be used.", - }, &description); err != nil { - return errors.Wrap(err, "prompt") + if IsInteractive { + if err := survey.AskOne(&survey.Input{ + Message: "Repo Description:", + Help: "Description for your new repository. If left empty, a default description message will be used.", + }, &description); err != nil { + return errors.Wrap(err, "prompt") + } + } else if !isQuiet { + printLinef(cmd, "Default description message will be used for your new repository\n") } } - fmt.Printf("Attempting to create a new configuration repository %s in team %s.\n", logging.Bold(fmt.Sprintf("[%s/%s]", owner, repoName)), rs.GetLekkoTeam()) - fmt.Printf("First, ensure that the github owner '%s' has installed Lekko App by visiting:\n\t%s\n", owner, lekkoAppInstallURL) - fmt.Printf("Once done, press [Enter] to continue...") - _ = waitForEnter(os.Stdin) - repo := repo.NewRepoCmd(lekko.NewBFFClient(rs)) - url, err := repo.Create(cmd.Context(), owner, repoName, description) - if err != nil { - return err + if IsInteractive && !isQuiet { + fmt.Printf("Attempting to create a new configuration repository %s in team %s.\n", logging.Bold(fmt.Sprintf("[%s/%s]", owner, repoName)), rs.GetLekkoTeam()) + fmt.Printf("First, ensure that the github owner '%s' has installed Lekko App by visiting:\n\t%s\n", owner, lekkoAppInstallURL) + fmt.Printf("Once done, press [Enter] to continue...") + _ = waitForEnter(os.Stdin) + } + + if !isDryRun { + rpo := repo.NewRepoCmd(lekko.NewBFFClient(rs)) + url, err := rpo.Create(cmd.Context(), owner, repoName, description) + url = strings.TrimRight(url, ".git") + if err != nil { + return err + } + if !isQuiet { + printLinef(cmd, "Created a new config repository %s/%s at %s\n", owner, repoName, url) + if IsInteractive { + printLinef(cmd, "To make configuration changes, run `lekko repo clone`\n") + } + } else { + printLinef(cmd, "%s", url) + } + } else { + if !isQuiet { + printLinef(cmd, "Created a new config repository %s/%s\n", owner, repoName) + fmt.Printf("To make configuration changes, run `lekko repo clone`.") + } else { + fmt.Printf("%s/%s", owner, repoName) + } } - fmt.Printf("Successfully created a new config repository at %s.\n", url) - fmt.Printf("To make configuration changes, run `lekko repo clone`.") return nil }, } - cmd.Flags().StringVarP(&owner, "owner", "o", "", "GitHub owner to create the repository under. If empty, defaults to the authorized user's personal account.") - cmd.Flags().StringVarP(&repoName, "repo", "r", "", "GitHub repository name") - cmd.Flags().StringVarP(&description, "description", "d", "", "GitHub repository description") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().StringVarP(&description, "description", "m", "", "GitHub repository description") + return cmd } func repoCloneCmd() *cobra.Command { - var url string + var isDryRun, isQuiet bool + var url, repoPath string cmd := &cobra.Command{ - Use: "clone", - Short: "Clone an existing configuration repository to local disk", + Short: "Clone an existing configuration repository to local disk", + Use: formCmdUse("clone", "repoUrl"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"repoUrl"}, args); err != nil { + return err + } wd, err := os.Getwd() if err != nil { return err @@ -142,6 +214,9 @@ func repoCloneCmd() *cobra.Command { rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) r := repo.NewRepoCmd(lekko.NewBFFClient(rs)) ctx := cmd.Context() + rArgs, _ := getNArgs(1, args) + url = rArgs[0] + if len(url) == 0 { var options []string repos, err := r.List(ctx) @@ -164,23 +239,40 @@ func repoCloneCmd() *cobra.Command { } } repoName := path.Base(url) - fmt.Printf("Cloning %s into '%s'...\n", url, repoName) - _, err = repo.NewLocalClone(path.Join(wd, repoName), url, rs) + if repoPath, err = filepath.Abs(repoName); err != nil { + return errors.Wrap(err, "clone") + } + if !isQuiet { + printLinef(cmd, "cloning '%s' of url %s into '%s'... ", repoName, url, repoPath) + } + if !isDryRun { + _, err = repo.NewLocalClone(path.Join(wd, repoName), url, rs) + } if err != nil { return errors.Wrap(err, "new local clone") + } else if !isQuiet { + printLinef(cmd, "completed\n") + } else { + printLinef(cmd, "%s", repoPath) } return nil }, } - cmd.Flags().StringVarP(&url, "url", "u", "", "url of GitHub-hosted configuration repository to clone") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) return cmd } func repoDeleteCmd() *cobra.Command { + var isQuiet, isDryRun, isForce, isDeleteOnGithub bool cmd := &cobra.Command{ - Use: "delete", - Short: "Delete an existing config repository", + Short: "Delete an existing config repository", + Use: formCmdUse("delete", "owner/repoName"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"owner/repoName"}, args); err != nil { + return err + } rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) repo := repo.NewRepoCmd(lekko.NewBFFClient(rs)) ctx := cmd.Context() @@ -188,44 +280,71 @@ func repoDeleteCmd() *cobra.Command { if err != nil { return errors.Wrap(err, "repos list") } - var options []string - for _, r := range repos { - options = append(options, fmt.Sprintf("%s/%s", r.Owner, r.RepoName)) - } var selected string - if err := survey.AskOne(&survey.Select{ - Message: "Repository to delete:", - Options: options, - }, &selected); err != nil { - return errors.Wrap(err, "prompt") + rArgs, n := getNArgs(1, args) + + if n == 1 { + selected = rArgs[0] + } else { + var options []string + for _, r := range repos { + options = append(options, fmt.Sprintf("%s/%s", r.Owner, r.RepoName)) + } + + if err := survey.AskOne(&survey.Select{ + Message: "Repository to delete:", + Options: options, + }, &selected); err != nil { + return errors.Wrap(err, "prompt") + } } paths := strings.Split(selected, "/") if len(paths) != 2 { return errors.Errorf("malformed selection: %s", selected) } owner, repoName := paths[0], paths[1] - var deleteOnGithub bool - if err := survey.AskOne(&survey.Confirm{ - Message: "Also delete on GitHub?", - Help: "y/Y: repo is deleted on Github. n/N: repo remains on Github but is unlinked from Lekko.", - }, &deleteOnGithub); err != nil { - return errors.Wrap(err, "prompt") - } - text := "Unlinking repository '%s' from Lekko...\n" - if deleteOnGithub { - text = "Deleting repository '%s' from Github and Lekko...\n" + + if !isForce || IsInteractive { + if !isDeleteOnGithub { + if err := survey.AskOne(&survey.Confirm{ + Message: "Also delete on GitHub?", + Help: "y/Y: repo is deleted on Github. n/N: repo remains on Github but is unlinked from Lekko.", + }, &isDeleteOnGithub); err != nil { + return errors.Wrap(err, "prompt") + } + } + text := "Unlinking repository '%s' from Lekko...\n" + if isDeleteOnGithub { + text = "Deleting repository '%s' from Github and Lekko...\n" + } + fmt.Printf(text, selected) + if err := confirmInput(selected); err != nil { + return err + } + } else { + isDeleteOnGithub = true + if !isQuiet { + text := "Deleting repository '%s' from Github and Lekko...\n" + fmt.Printf(text, selected) + } } - fmt.Printf(text, selected) - if err := confirmInput(selected); err != nil { - return err + if !isDryRun { + if err := repo.Delete(ctx, owner, repoName, isDeleteOnGithub); err != nil { + return errors.Wrap(err, "delete repo") + } } - if err := repo.Delete(ctx, owner, repoName, deleteOnGithub); err != nil { - return errors.Wrap(err, "delete repo") + if !isQuiet { + printLinef(cmd, "Successfully deleted repository %s.\n", selected) + } else { + printLinef(cmd, "%s", selected) } - fmt.Printf("Successfully deleted repository %s.\n", selected) return nil }, } + cmd.Flags().BoolVarP(&isDeleteOnGithub, DeleteOnGitHubFlag, DeleteOnGitHubFlagShort, DeleteOnGitHubFlagDVal, DeleteOnGitHubFlagDescription) + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().BoolVarP(&isForce, ForceFlag, ForceFlagShort, ForceFlagDVal, ForceFlagDescriptionLocal) return cmd } @@ -262,3 +381,18 @@ func repoInitCmd() *cobra.Command { cmd.Flags().StringVarP(&description, "description", "d", "", "GitHub repository description") return cmd } + +func printRepos(cmd *cobra.Command, repos []*repo.Repository) { + if isQuiet, _ := cmd.Flags().GetBool(QuietModeFlag); isQuiet { + reposStr := "" + for _, r := range repos { + reposStr += fmt.Sprintf("%s ", r.URL) + } + reposStr = strings.TrimSpace(reposStr) + printLinef(cmd, "%s", strings.TrimSpace(reposStr)) + } else { + for _, r := range repos { + fmt.Printf("%s:\n\t%s\n\t%s\n", logging.Bold(fmt.Sprintf("[%s/%s]", r.Owner, r.RepoName)), r.Description, r.URL) + } + } +} diff --git a/cmd/lekko/restore.go b/cmd/lekko/restore.go new file mode 100644 index 00000000..0886a911 --- /dev/null +++ b/cmd/lekko/restore.go @@ -0,0 +1,79 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func restoreCmd() *cobra.Command { + var isQuiet, force bool + cmd := &cobra.Command{ + Short: "Restores repo to a given hash", + Use: formCmdUse("restore", "hash"), + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) + r, err := repo.NewLocal(wd, rs) + if isQuiet { + r.ConfigureLogger(nil) + } + if err != nil { + return errors.Wrap(err, "new repo") + } + if err := r.RestoreWorkingDirectory(args[0]); err != nil { + return errors.Wrap(err, "restore wd") + } + ctx := cmd.Context() + rootMD, _, err := r.ParseMetadata(ctx) + if err != nil { + return errors.Wrap(err, "parse metadata") + } + registry, err := r.ReBuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory, rootMD.UseExternalTypes) + if err != nil { + return errors.Wrap(err, "rebuild type registry") + } + if !isQuiet { + printLinef(cmd, "Successfully rebuilt dynamic type registry.\n") + } + if _, err := r.Compile(ctx, &repo.CompileRequest{ + Registry: registry, + DryRun: false, + IgnoreBackwardsCompatibility: force, + }); err != nil { + return errors.Wrap(err, "compile") + } + if !isQuiet { + printLinef(cmd, "Restored hash %s to your working directory. \nRun `lekko review` to create a PR with these changes.\n", args[0]) + } else { + printLinef(cmd, "%s", args[0]) + } + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&force, "force", "f", false, "force compilation, ignoring validation check failures.") + return cmd +} diff --git a/cmd/lekko/review.go b/cmd/lekko/review.go new file mode 100644 index 00000000..eb48f54c --- /dev/null +++ b/cmd/lekko/review.go @@ -0,0 +1,83 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "path" + + "github.com/AlecAivazis/survey/v2" + "github.com/lekkodev/cli/pkg/gh" + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func reviewCmd() *cobra.Command { + var title, url string + var isDryRun, isQuiet bool + cmd := &cobra.Command{ + Short: "Creates a pr with your changes", + Use: formCmdUse("review"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireGithub()) + r, err := repo.NewLocal(wd, rs) + + if isQuiet { + r.ConfigureLogger(nil) + } + if err != nil { + return errors.Wrap(err, "review") + } + + if _, err := r.Verify(ctx, &repo.VerifyRequest{}); err != nil { + return errors.Wrap(err, "verify") + } + ghCli := gh.NewGithubClientFromToken(ctx, rs.GetGithubToken()) + if _, err := ghCli.GetUser(ctx); err != nil { + return errors.Wrap(err, "github auth fail") + } + + if len(title) == 0 && IsInteractive { + fmt.Printf("-------------------\n") + if err := survey.AskOne(&survey.Input{ + Message: "Title:", + }, &title); err != nil { + return errors.Wrap(err, "prompt") + } + } + + if !isDryRun { + url, err = r.Review(ctx, title, ghCli, rs) + if isQuiet && err == nil { + printLinef(cmd, "%s %s", path.Base(url), url) + } + } + return err + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) + cmd.Flags().StringVarP(&title, "title", "t", "", "Title of pull request") + return cmd +} diff --git a/cmd/lekko/team.go b/cmd/lekko/team.go index 1b485816..83690187 100644 --- a/cmd/lekko/team.go +++ b/cmd/lekko/team.go @@ -20,6 +20,7 @@ import ( "log" "net/mail" "os" + "strings" "text/tabwriter" "github.com/AlecAivazis/survey/v2" @@ -33,37 +34,50 @@ import ( func teamCmd() *cobra.Command { cmd := &cobra.Command{ Use: "team", - Short: "team management", + Short: "Team management", } cmd.AddCommand( - showCmd, + teamShowCmd(), teamListCmd(), teamSwitchCmd(), - createCmd(), - addMemberCmd(), - removeMemberCmd(), + teamCreateCmd(), + teamAddMemberCmd(), + teamRemoveMemberCmd(), teamListMembersCmd(), ) return cmd } -var showCmd = &cobra.Command{ - Use: "show", - Short: "Show the team currently in use", - RunE: func(cmd *cobra.Command, args []string) error { - rs := secrets.NewSecretsOrFail(secrets.RequireLekkoToken()) - t := team.NewTeam(lekko.NewBFFClient(rs)) - fmt.Println(t.Show(rs)) - return nil - }, +func teamShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Short: "Show the team currently in use", + Use: formCmdUse("show", FlagOptions), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{}, args); err != nil { + return err + } + rs := secrets.NewSecretsOrFail(secrets.RequireLekkoToken()) + t := team.NewTeam(lekko.NewBFFClient(rs)) + printLinef(cmd, "%s\n", t.Show(rs)) + return nil + }, + } + cmd.Flags().BoolP(QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd } func teamListCmd() *cobra.Command { + var isQuiet bool var flagOutput string cmd := &cobra.Command{ - Use: "list", - Short: "list the teams that the logged-in user is a member of", + Short: "List the teams that the logged-in user is a member of", + Use: formCmdUse("list", FlagOptions), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{}, args); err != nil { + return err + } rs := secrets.NewSecretsOrFail(secrets.RequireLekkoToken()) t := team.NewTeam(lekko.NewBFFClient(rs)) memberships, err := t.List(cmd.Context()) @@ -71,31 +85,43 @@ func teamListCmd() *cobra.Command { return err } if len(memberships) == 0 { - fmt.Printf("User '%s' has no team memberhips\n", rs.GetLekkoUsername()) + if !IsInteractive { + fmt.Printf("User '%s' has no team memberhips\n", rs.GetLekkoUsername()) + } return nil } - printTeamMemberships(memberships, flagOutput) + printTeamMemberships(cmd, memberships, flagOutput) return nil }, } - cmd.Flags().StringVarP(&flagOutput, "output", "o", "table", "Output format. ['json', 'table']") + cmd.Flags().StringVarP(&flagOutput, "output", "o", "table", "output format: ['json', 'table']") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) return cmd } func teamSwitchCmd() *cobra.Command { + var isDryRun, isQuiet bool var name string cmd := &cobra.Command{ - Use: "switch", - Short: "switch the team currently in use", + Short: "Switch the team currently in use", + Use: formCmdUse("switch", "name"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"name"}, args); err != nil { + return err + } if err := secrets.WithWriteSecrets(func(ws secrets.WriteSecrets) error { t := team.NewTeam(lekko.NewBFFClient(ws)) ctx := cmd.Context() + + rArgs, _ := getNArgs(1, args) + name = rArgs[0] + + memberships, err := t.List(ctx) + if err != nil { + return err + } if len(name) == 0 { - memberships, err := t.List(ctx) - if err != nil { - return err - } var options []string for _, m := range memberships { options = append(options, m.TeamName) @@ -106,28 +132,56 @@ func teamSwitchCmd() *cobra.Command { }, &name); err != nil { return errors.Wrap(err, "prompt") } + } else { + isTeam := false + for _, tm := range memberships { + if tm.TeamName == name { + isTeam = true + break + } + } + if !isTeam { + return errors.New(" No team with the given name found") + } } - if err := t.Use(ctx, name, ws); err != nil { - return err + if !isDryRun { + if err := t.Use(ctx, name, ws); err != nil { + return err + } } return nil }, secrets.RequireLekkoToken()); err != nil { return err } - fmt.Printf("Switched team to '%s'\n", name) + + if !isQuiet { + printLinef(cmd, "Switched team to '%s'\n", name) + } else { + fmt.Printf("%s", name) + } + return nil }, } - cmd.Flags().StringVarP(&name, "name", "n", "", "name of team to switch to") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) return cmd } -func createCmd() *cobra.Command { +func teamCreateCmd() *cobra.Command { + var isDryRun, isQuiet bool var name string cmd := &cobra.Command{ - Use: "create", - Short: "create a lekko team", + Short: "Create a lekko team", + Use: formCmdUse("create", "name"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"name"}, args); err != nil { + return err + } + rArgs, _ := getNArgs(1, args) + name = rArgs[0] + if len(name) == 0 { if err := survey.AskOne(&survey.Input{ Message: "Team Name:", @@ -135,38 +189,54 @@ func createCmd() *cobra.Command { return errors.Wrap(err, "prompt") } } - if err := secrets.WithWriteSecrets(func(ws secrets.WriteSecrets) error { - return team.NewTeam(lekko.NewBFFClient(ws)).Create(cmd.Context(), name, ws) - }, secrets.RequireLekkoToken()); err != nil { - return err + if !isDryRun { + if err := secrets.WithWriteSecrets(func(ws secrets.WriteSecrets) error { + return team.NewTeam(lekko.NewBFFClient(ws)).Create(cmd.Context(), name, ws) + }, secrets.RequireLekkoToken()); err != nil { + return err + } + } + if !isQuiet { + printLinef(cmd, "Created team %s, and switched to it \n", name) + } else { + fmt.Printf("%s", name) } - fmt.Printf("Successfully created team %s, and switched to it", name) return nil }, } - cmd.Flags().StringVarP(&name, "name", "n", "", "name of team to create") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) return cmd } -func addMemberCmd() *cobra.Command { +func teamAddMemberCmd() *cobra.Command { + var isDryRun, isQuiet bool var email string var role team.MemberRole cmd := &cobra.Command{ - Use: "add-member", - Short: "add an existing lekko user as a member to the currently active team", + Short: "Add an existing lekko user as a member to the currently active team", + Use: formCmdUse("add-member", "email", "role"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"email", "role"}, args); err != nil { + return err + } + rArgs, _ := getNArgs(2, args) + + email = rArgs[0] if len(email) == 0 { if err := survey.AskOne(&survey.Input{ Message: "Email to add:", }, &email); err != nil { return errors.Wrap(err, "prompt") } - if _, err := mail.ParseAddress(email); err != nil { - return errors.Wrap(err, "invalid email") - } } - if len(role) == 0 { - var roleStr string + if _, err := mail.ParseAddress(email); err != nil { + return errors.Wrap(err, "invalid email") + } + roleStr := rArgs[1] + if len(roleStr) == 0 { + //var roleStr string if err := survey.AskOne(&survey.Select{ Message: "Role:", Options: []string{string(team.MemberRoleMember), string(team.MemberRoleOwner)}, @@ -174,26 +244,46 @@ func addMemberCmd() *cobra.Command { return errors.Wrap(err, "prompt") } role = team.MemberRole(roleStr) + } else { + role = team.MemberRole(roleStr) + if role != team.MemberRoleOwner && role != team.MemberRoleMember { + return errors.New("unrecognized team role") + } } rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) - if err := team.NewTeam(lekko.NewBFFClient(rs)).AddMember(cmd.Context(), email, role); err != nil { - return errors.Wrap(err, "add member") + + if !isDryRun { + if err := team.NewTeam(lekko.NewBFFClient(rs)).AddMember(cmd.Context(), email, role); err != nil { + return errors.Wrap(err, "add member") + } + } + + if !isQuiet { + printLinef(cmd, "User %s added as %s to team %s\n", email, role, rs.GetLekkoTeam()) + } else { + printLinef(cmd, "%s", email) } - fmt.Printf("User %s added as %s to team %s", email, role, rs.GetLekkoTeam()) return nil }, } - cmd.Flags().StringVarP(&email, "email", "e", "", "email of existing lekko user to add") - cmd.Flags().VarP(&role, "role", "r", "role to give member. allowed: 'owner', 'member'.") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) return cmd } -func removeMemberCmd() *cobra.Command { +func teamRemoveMemberCmd() *cobra.Command { + var isDryRun, isQuiet bool var email string cmd := &cobra.Command{ - Use: "remove-member", - Short: "remove a member from the currently active team", + Short: "Remove a member from the currently active team", + Use: formCmdUse("remove-member", "email"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + if err := errIfMoreArgs([]string{"email"}, args); err != nil { + return err + } + rArgs, _ := getNArgs(1, args) + email = rArgs[0] if len(email) == 0 { if err := survey.AskOne(&survey.Input{ Message: "Email to remove:", @@ -201,25 +291,32 @@ func removeMemberCmd() *cobra.Command { return errors.Wrap(err, "prompt") } } - rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) - if err := team.NewTeam(lekko.NewBFFClient(rs)).RemoveMember(cmd.Context(), email); err != nil { - return errors.Wrap(err, "remove member") + if !isDryRun { + if err := team.NewTeam(lekko.NewBFFClient(rs)).RemoveMember(cmd.Context(), email); err != nil { + return errors.Wrap(err, "remove member") + } + } + if !isQuiet { + printLinef(cmd, "User %s removed from team %s\n", email, rs.GetLekkoTeam()) + } else { + printLinef(cmd, "%s", email) } - fmt.Printf("User %s removed from team %s", email, rs.GetLekkoTeam()) return nil }, } - cmd.Flags().StringVarP(&email, "email", "e", "", "email of existing lekko user to add") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().BoolVarP(&isDryRun, DryRunFlag, DryRunFlagShort, DryRunFlagDVal, DryRunFlagDescription) return cmd } func teamListMembersCmd() *cobra.Command { var flagOutput string - + var isQuiet bool cmd := &cobra.Command{ - Use: "list-members", - Short: "list the members of the currently active team", + Short: "List the members of the currently active team", + Use: formCmdUse("list-members"), + DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { rs := secrets.NewSecretsOrFail(secrets.RequireLekko()) t := team.NewTeam(lekko.NewBFFClient(rs)) @@ -228,33 +325,51 @@ func teamListMembersCmd() *cobra.Command { return err } if len(memberships) == 0 { - fmt.Printf("Team '%s' has no memberhips\n", rs.GetLekkoTeam()) + if !isQuiet { + fmt.Printf("Team '%s' has no memberhips\n", rs.GetLekkoTeam()) + } return nil } - printTeamMemberships(memberships, flagOutput) + + printTeamMemberships(cmd, memberships, flagOutput) return nil }, } - cmd.Flags().StringVarP(&flagOutput, "output", "o", "table", "Output format. ['json', 'table']") + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + cmd.Flags().StringVarP(&flagOutput, "output", "o", "table", "output format: ['json', 'table']") return cmd } -func printTeamMemberships(memberships []*team.TeamMembership, output string) { - switch output { - case "table": - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - fmt.Fprintf(w, "Team Name\tEmail\tRole\tStatus\n") +func printTeamMemberships(cmd *cobra.Command, memberships []*team.TeamMembership, output string) { + if isQuiet, _ := cmd.Flags().GetBool(QuietModeFlag); isQuiet { + out := "" for _, m := range memberships { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", m.TeamName, m.User, m.Role, m.UserStatus) + switch cmd.Name() { + case "list-members": + out += fmt.Sprintf("%s ", m.User) + case "list": + out += fmt.Sprintf("%s ", m.TeamName) + } } - w.Flush() - case "json": - b, err := json.MarshalIndent(memberships, "", " ") - if err != nil { - log.Fatalf("unable to print team memberships: %v", err) + out = strings.TrimSpace(out) + printLinef(cmd, "%s", out) + } else { + switch output { + case "table": + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + _, _ = fmt.Fprintf(w, "Team Name\tEmail\tRole\tStatus\n") + for _, m := range memberships { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", m.TeamName, m.User, m.Role, m.UserStatus) + } + _ = w.Flush() + case "json": + b, err := json.MarshalIndent(memberships, "", " ") + if err != nil { + log.Fatalf("unable to print team memberships: %v", err) + } + fmt.Println(string(b)) + default: + fmt.Printf("unknown output format: %s", output) } - fmt.Println(string(b)) - default: - fmt.Printf("unknown output format: %s", output) } } diff --git a/cmd/lekko/utils.go b/cmd/lekko/utils.go new file mode 100644 index 00000000..3d5018bc --- /dev/null +++ b/cmd/lekko/utils.go @@ -0,0 +1,168 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/pkg/errors" + + "github.com/lekkodev/cli/pkg/logging" + "github.com/spf13/cobra" +) + +// Helpful method to ask the user to enter a piece of text before +// doing something irreversible, like deleting something. +func confirmInput(text string) error { + var inputText string + if err := survey.AskOne(&survey.Input{ + Message: fmt.Sprintf("Enter '%s' to continue:", text), + }, &inputText); err != nil { + return errors.Wrap(err, "prompt") + } + if text != inputText { + return errors.New("incorrect input") + } + return nil +} + +// printLinef prints according to provided format, removing leading and trailing +// white space characters in quiet mode. It is intended to print single line for print-outs +// with formatting done only in the format string +func printLinef(cmd *cobra.Command, format string, params ...interface{}) { + if isDryRun, _ := cmd.Flags().GetBool(DryRunFlag); isDryRun { + format = "[Dry Run]: " + format + } + if isQuiet, _ := cmd.Flags().GetBool(QuietModeFlag); isQuiet { + format = strings.TrimSpace(format) + } + fmt.Printf(format, params...) +} + +// printErr prints error message on stderr, using in bold and red (on Mac OS). +// In non-quiet mode, newline is added. +func printErr(cmd *cobra.Command, err error) { + if isQuiet, _ := cmd.Flags().GetBool(QuietModeFlag); !isQuiet { + _, _ = fmt.Fprintln(os.Stderr, logging.Bold(logging.Red("Error: "+err.Error()))) + } else { + _, _ = fmt.Fprint(os.Stderr, logging.Bold(logging.Red("Error: "+err.Error()))) + } +} + +func formCmdUse(cmd string, args ...string) string { + return fmt.Sprintf("%s %s %s", cmd, FlagOptions, strings.Join(args, " ")) +} +func printErrExit(cmd *cobra.Command, err error) { + printErr(cmd, err) + os.Exit(1) +} + +func getNArgs(nArgs int, args []string) (retArgs []string, n int) { + maxN := len(args) + if maxN < nArgs { + maxN = nArgs + } + retArgs = make([]string, maxN) + n = len(args) + for i := 0; i < n; i++ { + retArgs[i] = args[i] + } + //fmt.Printf("\n*%v*\n", retArgs) + return retArgs, n +} + +// splits string into a slice of desired capacity +func splitStrIntoFixedSlice(str string, tDel string, num int) (rs []string, err error) { + if !strings.Contains(str, tDel) { + return rs, fmt.Errorf("required token delimiter '%s' not found in %s", tDel, str) + } + rs = make([]string, num) + ss := strings.Split(str, tDel) + for i, j := 0, 0; i < len(rs) && i < len(ss); i, j = i+1, j+1 { + rs[i] = ss[j] + } + return rs, err +} + +func errIfMoreArgs(eArgs, args []string) error { + if len(eArgs) < len(args) { + if len(eArgs) == 0 { + return errors.New(fmt.Sprintf("wrong number of arguments - no args expected, received %d (%s)", len(args), strings.Join(args, " "))) + } else { + return errors.New(fmt.Sprintf("wrong number of arguments - expected %d (%s), received %d (%s)", len(eArgs), strings.Join(eArgs, " "), len(args), strings.Join(args, " "))) + } + } + return nil +} + +func errIfLessArgs(eArgs, args []string) error { + if len(eArgs) > len(args) { + argsStr := "" + if len(args) > 0 { + argsStr = strings.Join(args, " ") + } + if len(argsStr) > 0 { + argsStr = "(" + argsStr + ")" + } + return errors.New(fmt.Sprintf("wrong number of arguments - required %d (%s), received %d args %s", len(eArgs), strings.Join(eArgs, " "), len(args), argsStr)) + } + return nil +} + +type UseCmdParams struct { + App string + Name string + Cmds []string + PosArgs []string + PosOptionalArgs []string + FlagOptions string +} + +func getUseCmdParams(useLine, cmdName string) *UseCmdParams { + var useCmdParams UseCmdParams + useCmdParams.Cmds = []string{} + useCmdParams.PosArgs = []string{} + useCmdParams.PosOptionalArgs = []string{} + + useLineArr := strings.Split(useLine, " ") + useCmdParams.App = useLineArr[0] + useCmdParams.Name = cmdName + index := 1 + useLineArr = useLineArr[index:] + + var p string + for _, p = range useLineArr { + useCmdParams.Cmds = append(useCmdParams.Cmds, p) + index++ + if p == cmdName { + break + } + } + + useLineArr = useLineArr[index:] + for _, p = range useLineArr { + if p == FlagOptions { + useCmdParams.FlagOptions = p + } else if strings.Contains(p, "[") && strings.Contains(p, "]") { + useCmdParams.PosOptionalArgs = append(useCmdParams.PosOptionalArgs, p) + } else if len(p) > 0 { + useCmdParams.PosArgs = append(useCmdParams.PosArgs, p) + } + } + return &useCmdParams +} diff --git a/cmd/lekko/verify.go b/cmd/lekko/verify.go new file mode 100644 index 00000000..93583fac --- /dev/null +++ b/cmd/lekko/verify.go @@ -0,0 +1,83 @@ +// Copyright 2022 Lekko Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/lekkodev/cli/pkg/feature" + "github.com/lekkodev/cli/pkg/repo" + "github.com/lekkodev/cli/pkg/secrets" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func verifyCmd() *cobra.Command { + var isQuiet bool + cmd := &cobra.Command{ + Short: "Verifies features based on individual definitions", + Use: formCmdUse("verify", "[namespace[/feature]]"), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + rs := secrets.NewSecretsOrFail() + r, err := repo.NewLocal(wd, rs) + if err != nil { + return err + } + if isQuiet { + r.ConfigureLogger(nil) + } + ctx := cmd.Context() + rootMD, _, err := r.ParseMetadata(ctx) + if err != nil { + return errors.Wrap(err, "parse metadata") + } + registry, err := r.ReBuildDynamicTypeRegistry(ctx, rootMD.ProtoDirectory, rootMD.UseExternalTypes) + if err != nil { + return errors.Wrap(err, "rebuild type registry") + } + var ns, f string + if len(args) > 0 { + ns, f, err = feature.ParseFeaturePath(args[0]) + if err != nil { + return err + } + } + + if featureCompRes, err := r.Verify(ctx, &repo.VerifyRequest{ + Registry: registry, + NamespaceFilter: ns, + FeatureFilter: f, + }); err != nil { + return err + } else if isQuiet { + s := "" + for _, fcr := range featureCompRes { + s += fmt.Sprintf("%s/%s ", fcr.NamespaceName, fcr.FeatureName) + } + printLinef(cmd, "%s", strings.TrimSpace(s)) + } + return nil + }, + } + cmd.Flags().BoolVarP(&isQuiet, QuietModeFlag, QuietModeFlagShort, QuietModeFlagDVal, QuietModeFlagDescription) + return cmd +} diff --git a/go.mod b/go.mod index 61e8a9cb..d1bb3560 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/google/go-github/v52 v52.0.0 github.com/lekkodev/rules v1.5.2 github.com/migueleliasweb/go-github-mock v0.0.16 - github.com/mitchellh/go-homedir v1.1.0 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.5.0 github.com/stretchr/testify v1.7.0 @@ -61,6 +60,7 @@ require ( github.com/mattn/go-isatty v0.0.8 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index c30e67b6..a9ea8fbc 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -27,12 +27,17 @@ var green = "\033[32m" var bold = "\033[1m" var yellow = "\033[33m" -func InitColors() { +func InitColors(useColors bool) { if runtime.GOOS == "windows" { reset = "" red = "" green = "" bold = "" + } else if !useColors { + red = reset + green = reset + bold = "" + yellow = reset } } diff --git a/pkg/repo/feature.go b/pkg/repo/feature.go index 18e80d52..fdba78fb 100644 --- a/pkg/repo/feature.go +++ b/pkg/repo/feature.go @@ -44,11 +44,11 @@ import ( // This interface should make no assumptions about where the configuration is stored. type ConfigurationStore interface { Compile(ctx context.Context, req *CompileRequest) ([]*FeatureCompilationResult, error) - Verify(ctx context.Context, req *VerifyRequest) error + Verify(ctx context.Context, req *VerifyRequest) ([]*FeatureCompilationResult, error) BuildDynamicTypeRegistry(ctx context.Context, protoDirPath string) (*protoregistry.Types, error) ReBuildDynamicTypeRegistry(ctx context.Context, protoDirPath string, useExternalTypes bool) (*protoregistry.Types, error) GetFileDescriptorSet(ctx context.Context, protoDirPath string) (*descriptorpb.FileDescriptorSet, error) - Format(ctx context.Context, verbose bool) error + Format(ctx context.Context, verbose bool) (string, error) AddFeature(ctx context.Context, ns, featureName string, fType feature.FeatureType, protoMessageName string) (string, error) RemoveFeature(ctx context.Context, ns, featureName string) error AddNamespace(ctx context.Context, name string) error @@ -265,7 +265,7 @@ func (fcrs FeatureCompilationResults) Err() error { return nil } -func (r *repository) Verify(ctx context.Context, req *VerifyRequest) error { +func (r *repository) Verify(ctx context.Context, req *VerifyRequest) ([]*FeatureCompilationResult, error) { fcrs, err := r.Compile(ctx, &CompileRequest{ Registry: req.Registry, NamespaceFilter: req.NamespaceFilter, @@ -278,7 +278,7 @@ func (r *repository) Verify(ctx context.Context, req *VerifyRequest) error { Verbose: false, }) if err != nil { - return errors.Wrap(err, "compile") + return fcrs, errors.Wrap(err, "compile") } var hasDiff bool @@ -293,9 +293,9 @@ func (r *repository) Verify(ctx context.Context, req *VerifyRequest) error { } } if hasDiff { - return errors.New("Found feature(s) with compilation or formatting diffs") + return fcrs, errors.New("Found feature(s) with compilation or formatting diffs") } - return nil + return fcrs, nil } func (r *repository) Compile(ctx context.Context, req *CompileRequest) ([]*FeatureCompilationResult, error) { @@ -404,8 +404,9 @@ func (r *repository) Compile(ctx context.Context, req *CompileRequest) ([]*Featu r.Logf("Generated diff for %s/%s\n", fcr.NamespaceName, fcr.CompiledFeature.Feature.Key) } } - - results.RenderSummary(r) + if r.log != nil { + results.RenderSummary(r) + } if req.Upgrade && !req.DryRun { for ns := range namespaces { @@ -528,29 +529,32 @@ func (r *repository) GetFileDescriptorSet(ctx context.Context, protoDirPath stri return sTypes.FileDescriptorSet, nil } -func (r *repository) Format(ctx context.Context, verbose bool) error { +func (r *repository) Format(ctx context.Context, verbose bool) (string, error) { _, nsMDs, err := r.ParseMetadata(ctx) + formattedFeatures := "" if err != nil { - return errors.Wrap(err, "parse metadata") + return formattedFeatures, errors.Wrap(err, "parse metadata") } for ns := range nsMDs { ffs, err := r.GetFeatureFiles(ctx, ns) if err != nil { - return errors.Wrap(err, "get feature files") + return formattedFeatures, errors.Wrap(err, "get feature files") } for _, ff := range ffs { ff := ff formatted, _, err := r.FormatFeature(ctx, &ff, feature.FeatureType("unknown"), nil, false, verbose) if err != nil { - return errors.Wrapf(err, "format feature '%s/%s", ff.NamespaceName, ff.Name) + return formattedFeatures, errors.Wrapf(err, "format feature '%s/%s", ff.NamespaceName, ff.Name) } if formatted { r.Logf("Formatted and rewrote %s/%s\n", ff.NamespaceName, ff.Name) + formattedFeatures += fmt.Sprintf("%s/%s ", ff.NamespaceName, ff.Name) } } + formattedFeatures = strings.TrimSpace(formattedFeatures) } - return nil + return formattedFeatures, nil } func (r *repository) FormatFeature(ctx context.Context, ff *feature.FeatureFile, fType feature.FeatureType, registry *protoregistry.Types, dryRun, verbose bool) (persisted, diffExists bool, err error) { diff --git a/pkg/repo/review.go b/pkg/repo/review.go index 82a446b3..bdaef8ae 100644 --- a/pkg/repo/review.go +++ b/pkg/repo/review.go @@ -31,7 +31,7 @@ import ( // TODO: generalize the arguments to this interface so that we're not just tied to github. type GitProvider interface { Review(ctx context.Context, title string, ghCli *gh.GithubClient, ap AuthProvider) (string, error) - Merge(ctx context.Context, prNum *int, ghCli *gh.GithubClient, ap AuthProvider) error + Merge(ctx context.Context, prNum *int, ghCli *gh.GithubClient, ap AuthProvider) (int, error) } // Review will open a pull request. It takes different actions depending on @@ -88,23 +88,24 @@ func (r *repository) Review(ctx context.Context, title string, ghCli *gh.GithubC return url, nil } -func (r *repository) Merge(ctx context.Context, prNum *int, ghCli *gh.GithubClient, ap AuthProvider) error { +func (r *repository) Merge(ctx context.Context, prNum *int, ghCli *gh.GithubClient, ap AuthProvider) (int, error) { + prNumRet := -1 if err := credentialsExist(ap); err != nil { - return err + return prNumRet, err } owner, repo, err := r.getOwnerRepo() if err != nil { - return errors.Wrap(err, "get owner repo") + return prNumRet, errors.Wrap(err, "get owner repo") } branchName, err := r.BranchName() if err != nil { - return errors.Wrap(err, "branch name") + return prNumRet, errors.Wrap(err, "branch name") } if prNum == nil { pr, err := r.getPRForBranch(ctx, owner, repo, branchName, ghCli) if err != nil { - return errors.Wrap(err, "get pr for branch") + return prNumRet, errors.Wrap(err, "get pr for branch") } prNum = pr.Number } @@ -113,17 +114,20 @@ func (r *repository) Merge(ctx context.Context, prNum *int, ghCli *gh.GithubClie MergeMethod: "squash", }) if err != nil { - return fmt.Errorf("ghCli merge pr %v: %w", resp.Status, err) + return prNumRet, fmt.Errorf("ghCli merge pr %v: %w", resp.Status, err) } + + prNumRet = *prNum + if result.GetMerged() { - r.Logf("PR #%d: %s\n", prNum, result.GetMessage()) + r.Logf("PR #%d: %s\n", *prNum, result.GetMessage()) } else { - return errors.New("Failed to merge pull request.") + return prNumRet, errors.New("Failed to merge pull request.") } if err := r.Cleanup(ctx, &branchName, ap); err != nil { - return errors.Wrap(err, "cleanup") + return prNumRet, errors.Wrap(err, "cleanup") } - return nil + return prNumRet, nil } // Generates a branch name based on the given github username,