From 9203c78b0b9fc37023a5bb5952a1a0edac2f9bfd Mon Sep 17 00:00:00 2001 From: Sunghoon Kang Date: Wed, 20 Sep 2023 02:12:12 +0900 Subject: [PATCH] Add `get promotions` command to CLI (#790) Signed-off-by: Sunghoon Kang Co-authored-by: Kent Rancourt --- internal/cli/get/get.go | 160 ++++++++------------------------- internal/cli/get/project.go | 52 ----------- internal/cli/get/projects.go | 70 +++++++++++++++ internal/cli/get/promotions.go | 127 ++++++++++++++++++++++++++ internal/cli/get/stage.go | 53 ----------- internal/cli/get/stages.go | 117 ++++++++++++++++++++++++ internal/cli/option/flag.go | 6 ++ 7 files changed, 355 insertions(+), 230 deletions(-) delete mode 100644 internal/cli/get/project.go create mode 100644 internal/cli/get/projects.go create mode 100644 internal/cli/get/promotions.go delete mode 100644 internal/cli/get/stage.go create mode 100644 internal/cli/get/stages.go diff --git a/internal/cli/get/get.go b/internal/cli/get/get.go index 78416b8f1..a311ac2cb 100644 --- a/internal/cli/get/get.go +++ b/internal/cli/get/get.go @@ -1,162 +1,72 @@ package get import ( - "strings" - "time" - "github.com/pkg/errors" "github.com/spf13/cobra" - "golang.org/x/exp/slices" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/util/duration" "k8s.io/cli-runtime/pkg/printers" "k8s.io/utils/pointer" kargoapi "github.com/akuity/kargo/api/v1alpha1" - "github.com/akuity/kargo/internal/cli/client" "github.com/akuity/kargo/internal/cli/option" ) func NewCommand(opt *option.Option) *cobra.Command { cmd := &cobra.Command{ - Use: "get [--project=project] (RESOURCE) [NAME]...", + Use: "get (RESOURCE) [NAME]...", Short: "Display one or many resources", - Args: cobra.MinimumNArgs(1), Example: ` # List all projects kargo get projects # List all stages in the project -kargo get --project= stages +kargo get stages --project=my-project -# List all stages in JSON output format -kargo get stages my-project -o json +# List all promotions for the given stage +kargo get promotions --project=my-project --stage=my-stage `, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - kargoSvcCli, err := client.GetClientFromConfig(ctx, opt) - if err != nil { - return errors.New("get client from config") - } - - resource := strings.ToLower(strings.TrimSpace(args[0])) - if resource == "" { - return errors.New("resource is required") - } - - var resErr error - res := &metav1.List{ - TypeMeta: metav1.TypeMeta{ - APIVersion: metav1.Unversioned.String(), - Kind: "List", - }, - } - switch resource { - case "project", "projects": - names := slices.Compact(args[1:]) - filter, err := filterProjects(ctx, kargoSvcCli) - if err != nil { - return err - } - - var projects []runtime.Object - projects, resErr = filter(names...) - res.Items = make([]runtime.RawExtension, 0, len(projects)) - for _, project := range projects { - res.Items = append(res.Items, runtime.RawExtension{Object: project}) - } - if len(names) == 1 { - if len(res.Items) == 1 { - _ = printResult(opt, res.Items[0].Object) - } - return resErr - } - case "stage", "stages": - project := opt.Project.OrElse("") - if project == "" { - return errors.New("project is required") - } - names := slices.Compact(args[1:]) - filter, err := filterStages(ctx, kargoSvcCli, project) - if err != nil { - return err - } - - var stages []runtime.Object - stages, resErr = filter(names...) - res.Items = make([]runtime.RawExtension, 0, len(stages)) - for _, stage := range stages { - res.Items = append(res.Items, runtime.RawExtension{Object: stage}) - } - if len(names) == 1 && len(res.Items) == 1 { - _ = printStageResult(opt, res.Items[0].Object) - } else { - _ = printStageResult(opt, res) - } - return resErr - default: - return errors.Errorf("unknown resource %q", resource) - } - _ = printResult(opt, res) - return resErr - }, } - option.OptionalProject(opt.Project)(cmd.Flags()) - opt.PrintFlags.AddFlags(cmd) + // Subcommands + cmd.AddCommand(newGetProjectsCommand(opt)) + cmd.AddCommand(newGetStagesCommand(opt)) + cmd.AddCommand(newGetPromotionsCommand(opt)) return cmd } -func printStageResult(opt *option.Option, res runtime.Object) error { - if pointer.StringDeref(opt.PrintFlags.OutputFormat, "") != "" { - return printResult(opt, res) +func printObjects[T runtime.Object](opt *option.Option, objects []T) error { + items := make([]runtime.RawExtension, len(objects)) + for i, obj := range objects { + items[i] = runtime.RawExtension{Object: obj} } - var items []runtime.RawExtension - if list, ok := res.(*metav1.List); ok { - items = list.Items - } else { - items = []runtime.RawExtension{{Object: res}} - } - table := &metav1.Table{ - ColumnDefinitions: []metav1.TableColumnDefinition{ - {Name: "Name", Type: "string"}, - {Name: "Current Freight", Type: "string"}, - {Name: "Health", Type: "string"}, - {Name: "Age", Type: "string"}, + list := &metav1.List{ + TypeMeta: metav1.TypeMeta{ + APIVersion: metav1.Unversioned.String(), + Kind: "List", }, - Rows: make([]metav1.TableRow, len(items)), + Items: items, } - for i, item := range items { - // This func is only ever passed Stages - stage := item.Object.(*kargoapi.Stage) // nolint: forcetypeassert - var currentFreightID string - if stage.Status.CurrentFreight != nil { - currentFreightID = stage.Status.CurrentFreight.ID - } - var health string - if stage.Status.Health != nil { - health = string(stage.Status.Health.Status) + + if pointer.StringDeref(opt.PrintFlags.OutputFormat, "") != "" { + printer, err := opt.PrintFlags.ToPrinter() + if err != nil { + return errors.Wrap(err, "new printer") } - table.Rows[i] = metav1.TableRow{ - Cells: []any{ - stage.Name, - currentFreightID, - health, - duration.HumanDuration(time.Since(stage.CreationTimestamp.Time)), - }, - Object: item, + if len(list.Items) == 1 { + return printer.PrintObj(list.Items[0].Object, opt.IOStreams.Out) } + return printer.PrintObj(list, opt.IOStreams.Out) } - return printers.NewTablePrinter(printers.PrintOptions{}).PrintObj(table, opt.IOStreams.Out) -} -func printResult(opt *option.Option, res runtime.Object) error { - if pointer.StringDeref(opt.PrintFlags.OutputFormat, "") == "" { - return printers.NewTablePrinter(printers.PrintOptions{}).PrintObj(res, opt.IOStreams.Out) - } - printer, err := opt.PrintFlags.ToPrinter() - if err != nil { - return errors.Wrap(err, "new printer") + var t T + switch any(t).(type) { + case *kargoapi.Stage: + table := newStageTable(list) + return printers.NewTablePrinter(printers.PrintOptions{}).PrintObj(table, opt.IOStreams.Out) + case *kargoapi.Promotion: + table := newPromotionTable(list) + return printers.NewTablePrinter(printers.PrintOptions{}).PrintObj(table, opt.IOStreams.Out) + default: + return printers.NewTablePrinter(printers.PrintOptions{}).PrintObj(list, opt.IOStreams.Out) } - return printer.PrintObj(res, opt.IOStreams.Out) } diff --git a/internal/cli/get/project.go b/internal/cli/get/project.go deleted file mode 100644 index f797ad0d9..000000000 --- a/internal/cli/get/project.go +++ /dev/null @@ -1,52 +0,0 @@ -package get - -import ( - "context" - goerrors "errors" - - "connectrpc.com/connect" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - - typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1" - v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1" - "github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect" -) - -type filterProjectsFunc func(names ...string) ([]runtime.Object, error) - -func filterProjects( - ctx context.Context, - kargoSvcCli svcv1alpha1connect.KargoServiceClient, -) (filterProjectsFunc, error) { - resp, err := kargoSvcCli.ListProjects(ctx, connect.NewRequest(&v1alpha1.ListProjectsRequest{ - /* explicitly empty */ - })) - if err != nil { - return nil, errors.Wrap(err, "list projects") - } - return func(names ...string) ([]runtime.Object, error) { - res := make([]runtime.Object, 0, len(resp.Msg.GetProjects())) - if len(names) == 0 { - for _, p := range resp.Msg.GetProjects() { - res = append(res, typesv1alpha1.FromProjectProto(p)) - } - return res, nil - } - - var resErr error - projects := make(map[string]*unstructured.Unstructured, len(resp.Msg.GetProjects())) - for _, p := range resp.Msg.GetProjects() { - projects[p.GetName()] = typesv1alpha1.FromProjectProto(p) - } - for _, name := range names { - if project, ok := projects[name]; ok { - res = append(res, project) - } else { - resErr = goerrors.Join(err, errors.Errorf("project %q not found", name)) - } - } - return res, resErr - }, nil -} diff --git a/internal/cli/get/projects.go b/internal/cli/get/projects.go new file mode 100644 index 000000000..588b7a4c3 --- /dev/null +++ b/internal/cli/get/projects.go @@ -0,0 +1,70 @@ +package get + +import ( + goerrors "errors" + + "connectrpc.com/connect" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/exp/slices" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1" + "github.com/akuity/kargo/internal/cli/client" + "github.com/akuity/kargo/internal/cli/option" + v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1" +) + +func newGetProjectsCommand(opt *option.Option) *cobra.Command { + cmd := &cobra.Command{ + Use: "projects [NAME...]", + Aliases: []string{"project"}, + Short: "Display one or many projects", + Example: ` +# List all projects +kargo get projects + +# List all projects in JSON output format +kargo get projects -o json +`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + kargoSvcCli, err := client.GetClientFromConfig(ctx, opt) + if err != nil { + return errors.New("get client from config") + } + resp, err := kargoSvcCli.ListProjects(ctx, connect.NewRequest(&v1alpha1.ListProjectsRequest{})) + if err != nil { + return errors.Wrap(err, "list projects") + } + + names := slices.Compact(args) + res := make([]*unstructured.Unstructured, 0, len(resp.Msg.GetProjects())) + var resErr error + if len(names) == 0 { + for _, p := range resp.Msg.GetProjects() { + res = append(res, typesv1alpha1.FromProjectProto(p)) + } + } else { + projectsByName := make(map[string]*unstructured.Unstructured, len(resp.Msg.GetProjects())) + for _, p := range resp.Msg.GetProjects() { + projectsByName[p.GetName()] = typesv1alpha1.FromProjectProto(p) + } + for _, name := range names { + if promo, ok := projectsByName[name]; ok { + res = append(res, promo) + } else { + resErr = goerrors.Join(err, errors.Errorf("project %q not found", name)) + } + } + } + if err := printObjects(opt, res); err != nil { + return err + } + return resErr + }, + } + opt.PrintFlags.AddFlags(cmd) + return cmd +} diff --git a/internal/cli/get/promotions.go b/internal/cli/get/promotions.go new file mode 100644 index 000000000..7db2c3697 --- /dev/null +++ b/internal/cli/get/promotions.go @@ -0,0 +1,127 @@ +package get + +import ( + goerrors "errors" + "time" + + "connectrpc.com/connect" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/exp/slices" + "google.golang.org/protobuf/proto" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/duration" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1" + "github.com/akuity/kargo/internal/cli/client" + "github.com/akuity/kargo/internal/cli/option" + v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1" +) + +type PromotionsFlags struct { + Stage option.Optional[string] +} + +func newGetPromotionsCommand(opt *option.Option) *cobra.Command { + flag := PromotionsFlags{ + Stage: option.OptionalString(), + } + cmd := &cobra.Command{ + Use: "promotions --project=project [--stage=stage] [NAME...]", + Aliases: []string{"promotion", "promos", "promo"}, + Short: "Display one or many promotions", + Example: ` +# List all promotions in the project +kargo get promotions --project=my-project + +# List all promotions in JSON output format +kargo get promotions --project=my-project -o json + +# List all promotions for the stage +kargo get promotions --project=my-project --stage=my-stage + +# Get a promotion in the project +kargo get promotions --project=my-project some-promotion +`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + project := opt.Project.OrElse("") + if project == "" { + return errors.New("project is required") + } + req := &v1alpha1.ListPromotionsRequest{ + Project: project, + } + if stage, ok := flag.Stage.Get(); ok { + req.Stage = proto.String(stage) + } + + kargoSvcCli, err := client.GetClientFromConfig(ctx, opt) + if err != nil { + return errors.New("get client from config") + } + resp, err := kargoSvcCli.ListPromotions(ctx, connect.NewRequest(req)) + if err != nil { + return errors.Wrap(err, "list promotions") + } + + names := slices.Compact(args) + res := make([]*kargoapi.Promotion, 0, len(resp.Msg.GetPromotions())) + var resErr error + if len(names) == 0 { + for _, p := range resp.Msg.GetPromotions() { + res = append(res, typesv1alpha1.FromPromotionProto(p)) + } + } else { + promotionsByName := make(map[string]*kargoapi.Promotion, len(resp.Msg.GetPromotions())) + for _, p := range resp.Msg.GetPromotions() { + promotionsByName[p.GetMetadata().GetName()] = typesv1alpha1.FromPromotionProto(p) + } + for _, name := range names { + if promo, ok := promotionsByName[name]; ok { + res = append(res, promo) + } else { + resErr = goerrors.Join(err, errors.Errorf("promotion %q not found", name)) + } + } + } + if err := printObjects(opt, res); err != nil { + return err + } + return resErr + }, + } + option.OptionalProject(opt.Project)(cmd.Flags()) + option.OptionalStage(flag.Stage)(cmd.Flags()) + opt.PrintFlags.AddFlags(cmd) + return cmd +} + +func newPromotionTable(list *metav1.List) *metav1.Table { + rows := make([]metav1.TableRow, len(list.Items)) + for i, item := range list.Items { + promo := item.Object.(*kargoapi.Promotion) // nolint: forcetypeassert + rows[i] = metav1.TableRow{ + Cells: []any{ + promo.GetName(), + promo.Spec.Stage, + promo.Spec.Freight, + promo.GetStatus().Phase, + duration.HumanDuration(time.Since(promo.CreationTimestamp.Time)), + }, + Object: list.Items[i], + } + } + return &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string"}, + {Name: "Stage", Type: "string"}, + {Name: "Freight", Type: "string"}, + {Name: "Phase", Type: "string"}, + {Name: "Age", Type: "string"}, + }, + Rows: rows, + } +} diff --git a/internal/cli/get/stage.go b/internal/cli/get/stage.go deleted file mode 100644 index 2d47f60da..000000000 --- a/internal/cli/get/stage.go +++ /dev/null @@ -1,53 +0,0 @@ -package get - -import ( - "context" - goerrors "errors" - - "connectrpc.com/connect" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/runtime" - - kargoapi "github.com/akuity/kargo/api/v1alpha1" - typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1" - v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1" - "github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect" -) - -type filterStagesFunc func(names ...string) ([]runtime.Object, error) - -func filterStages( - ctx context.Context, - kargoSvcCli svcv1alpha1connect.KargoServiceClient, - project string, -) (filterStagesFunc, error) { - resp, err := kargoSvcCli.ListStages(ctx, connect.NewRequest(&v1alpha1.ListStagesRequest{ - Project: project, - })) - if err != nil { - return nil, errors.Wrap(err, "list stages") - } - return func(names ...string) ([]runtime.Object, error) { - res := make([]runtime.Object, 0, len(resp.Msg.GetStages())) - if len(names) == 0 { - for _, s := range resp.Msg.GetStages() { - res = append(res, typesv1alpha1.FromStageProto(s)) - } - return res, nil - } - - var resErr error - stages := make(map[string]*kargoapi.Stage, len(resp.Msg.GetStages())) - for _, s := range resp.Msg.GetStages() { - stages[s.GetMetadata().GetName()] = typesv1alpha1.FromStageProto(s) - } - for _, name := range names { - if stage, ok := stages[name]; ok { - res = append(res, stage) - } else { - resErr = goerrors.Join(err, errors.Errorf("stage %q not found", name)) - } - } - return res, resErr - }, nil -} diff --git a/internal/cli/get/stages.go b/internal/cli/get/stages.go new file mode 100644 index 000000000..89d709b95 --- /dev/null +++ b/internal/cli/get/stages.go @@ -0,0 +1,117 @@ +package get + +import ( + goerrors "errors" + "time" + + "connectrpc.com/connect" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/exp/slices" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/duration" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1" + "github.com/akuity/kargo/internal/cli/client" + "github.com/akuity/kargo/internal/cli/option" + v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1" +) + +func newGetStagesCommand(opt *option.Option) *cobra.Command { + cmd := &cobra.Command{ + Use: "stages --project=project [NAME...]", + Aliases: []string{"stage"}, + Short: "Display one or many stages", + Example: ` +# List all stages in the project +kargo get stages --project=my-project + +# List all stages in JSON output format +kargo get stages --project=my-project -o json + +# Get a stage in the project +kargo get stages --project=my-project my-stage +`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + project := opt.Project.OrElse("") + if project == "" { + return errors.New("project is required") + } + + kargoSvcCli, err := client.GetClientFromConfig(ctx, opt) + if err != nil { + return errors.New("get client from config") + } + resp, err := kargoSvcCli.ListStages(ctx, connect.NewRequest(&v1alpha1.ListStagesRequest{ + Project: project, + })) + if err != nil { + return errors.Wrap(err, "list stages") + } + + names := slices.Compact(args) + res := make([]*kargoapi.Stage, 0, len(resp.Msg.GetStages())) + var resErr error + if len(names) == 0 { + for _, s := range resp.Msg.GetStages() { + res = append(res, typesv1alpha1.FromStageProto(s)) + } + } else { + stagesByName := make(map[string]*kargoapi.Stage, len(resp.Msg.GetStages())) + for _, s := range resp.Msg.GetStages() { + stagesByName[s.GetMetadata().GetName()] = typesv1alpha1.FromStageProto(s) + } + for _, name := range names { + if stage, ok := stagesByName[name]; ok { + res = append(res, stage) + } else { + resErr = goerrors.Join(err, errors.Errorf("stage %q not found", name)) + } + } + } + if err := printObjects(opt, res); err != nil { + return err + } + return resErr + }, + } + option.OptionalProject(opt.Project)(cmd.Flags()) + opt.PrintFlags.AddFlags(cmd) + return cmd +} + +func newStageTable(list *metav1.List) *metav1.Table { + rows := make([]metav1.TableRow, len(list.Items)) + for i, item := range list.Items { + stage := item.Object.(*kargoapi.Stage) // nolint: forcetypeassert + var currentFreightID string + if stage.Status.CurrentFreight != nil { + currentFreightID = stage.Status.CurrentFreight.ID + } + var health string + if stage.Status.Health != nil { + health = string(stage.Status.Health.Status) + } + rows[i] = metav1.TableRow{ + Cells: []any{ + stage.Name, + currentFreightID, + health, + duration.HumanDuration(time.Since(stage.CreationTimestamp.Time)), + }, + Object: list.Items[i], + } + } + return &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string"}, + {Name: "Current Freight", Type: "string"}, + {Name: "Health", Type: "string"}, + {Name: "Age", Type: "string"}, + }, + Rows: rows, + } +} diff --git a/internal/cli/option/flag.go b/internal/cli/option/flag.go index e53809737..de5e935ba 100644 --- a/internal/cli/option/flag.go +++ b/internal/cli/option/flag.go @@ -32,6 +32,12 @@ func OptionalProject(v Optional[string]) FlagFn { } } +func OptionalStage(v Optional[string]) FlagFn { + return func(fs *pflag.FlagSet) { + fs.Var(v, "stage", "Stage") + } +} + func Freight(v *string) FlagFn { return func(fs *pflag.FlagSet) { fs.StringVar(v, "freight", "", "Freight ID")