diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 697e72855..76757a410 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -47,5 +47,6 @@ stackit beta [flags] * [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake * [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (STACKIT File Storage) +* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume * [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN diff --git a/docs/stackit_beta_volume.md b/docs/stackit_beta_volume.md new file mode 100644 index 000000000..24270ad33 --- /dev/null +++ b/docs/stackit_beta_volume.md @@ -0,0 +1,34 @@ +## stackit beta volume + +Provides functionality for Volume + +### Synopsis + +Provides functionality for Volume. + +``` +stackit beta volume [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta volume" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta volume automation](./stackit_beta_volume_automation.md) - Provides functionality for Volume Automation + diff --git a/docs/stackit_beta_volume_automation.md b/docs/stackit_beta_volume_automation.md new file mode 100644 index 000000000..fedc7a4c3 --- /dev/null +++ b/docs/stackit_beta_volume_automation.md @@ -0,0 +1,34 @@ +## stackit beta volume automation + +Provides functionality for Volume Automation + +### Synopsis + +Provides functionality for Volume Automation. + +``` +stackit beta volume automation [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume +* [stackit beta volume automation template](./stackit_beta_volume_automation_template.md) - Provides functionality for Volume Automation Templates + diff --git a/docs/stackit_beta_volume_automation_template.md b/docs/stackit_beta_volume_automation_template.md new file mode 100644 index 000000000..381460a64 --- /dev/null +++ b/docs/stackit_beta_volume_automation_template.md @@ -0,0 +1,35 @@ +## stackit beta volume automation template + +Provides functionality for Volume Automation Templates + +### Synopsis + +Provides functionality for Volume Automation Templates. + +``` +stackit beta volume automation template [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation template" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta volume automation](./stackit_beta_volume_automation.md) - Provides functionality for Volume Automation +* [stackit beta volume automation template describe](./stackit_beta_volume_automation_template_describe.md) - Shows details of a Volume Automation Template +* [stackit beta volume automation template list](./stackit_beta_volume_automation_template_list.md) - List all Volume Automation Templates + diff --git a/docs/stackit_beta_volume_automation_template_describe.md b/docs/stackit_beta_volume_automation_template_describe.md new file mode 100644 index 000000000..d06fe5e0d --- /dev/null +++ b/docs/stackit_beta_volume_automation_template_describe.md @@ -0,0 +1,40 @@ +## stackit beta volume automation template describe + +Shows details of a Volume Automation Template + +### Synopsis + +Shows details of a Volume Automation Template. + +``` +stackit beta volume automation template describe TEMPLATE_ID [flags] +``` + +### Examples + +``` + Describe the Volume Automation Template with ID "xxx" + $ stackit beta volume automation template describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation template describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta volume automation template](./stackit_beta_volume_automation_template.md) - Provides functionality for Volume Automation Templates + diff --git a/docs/stackit_beta_volume_automation_template_list.md b/docs/stackit_beta_volume_automation_template_list.md new file mode 100644 index 000000000..9e9294dcc --- /dev/null +++ b/docs/stackit_beta_volume_automation_template_list.md @@ -0,0 +1,44 @@ +## stackit beta volume automation template list + +List all Volume Automation Templates + +### Synopsis + +List all Volume Automation Templates. + +``` +stackit beta volume automation template list [flags] +``` + +### Examples + +``` + List all Volume Automation Templates + $ stackit beta volume automation template list + + List up to 10 Volume Automation Templates + $ stackit beta volume automation template list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation template list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta volume automation template](./stackit_beta_volume_automation_template.md) - Provides functionality for Volume Automation Templates + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 6d2d7d01b..b371db82e 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -32,6 +32,7 @@ stackit config set [flags] --alb-waf-custom-endpoint string ALB WAF API base URL, used in calls to this API --allowed-url-domain string Domain name, used for the verification of the URLs that are given in the custom identity provider endpoint and "STACKIT curl" command --authorization-custom-endpoint string Authorization API base URL, used in calls to this API + --automation-custom-endpoint string Automation API base URL, used in calls to this API --cdn-custom-endpoint string CDN API base URL, used in calls to this API --dns-custom-endpoint string DNS API base URL, used in calls to this API --edge-custom-endpoint string Edge API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 255fd741f..d809e5e30 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -31,6 +31,7 @@ stackit config unset [flags] --assume-yes If set, skips all confirmation prompts --async Configuration option to run commands asynchronously --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL + --automation-custom-endpoint Automation API base URL. If unset, uses the default base URL --cdn-custom-endpoint Custom CDN endpoint URL. If unset, uses the default base URL --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL --edge-custom-endpoint Edge API base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index 45bc064ff..ec6b1ab57 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.2 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.3 + github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.1 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.23.1 github.com/stackitcloud/stackit-sdk-go/services/edge v0.13.2 diff --git a/go.sum b/go.sum index 2e9bbb27f..517ea6249 100644 --- a/go.sum +++ b/go.sum @@ -616,6 +616,8 @@ github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.2 h1:zY0ArhTg++8oOA github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.2/go.mod h1:4M9G1I64kZwlXO32ZoIpt0GAN4SpZ1SYerwCVVIBGoE= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.3 h1:YEuymF48gnBNqBsraBN5pycBDEyXcFYy/2bCdF+H3eY= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.3/go.mod h1:T/JF25XGJ3GqER/1L2N//DgY8x5tY7gA3N+/0nvmOWY= +github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.1 h1:naKG2lRw9ALCNaaJabhgtuCwSBVyOOlIX3NPkIFX1i0= +github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.1/go.mod h1:1vv2SZorcHJKxEPV15CnTDhavV+NcYSNjAX7cZGX31k= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0 h1:lCs1eq/OOTdwGrrkxkJ8qCnS6yLM4XZhznpML3nd1NI= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0/go.mod h1:NBgvZH5ekJ2hFCNI480C9MdJsa4989YGC1YgX5ELA48= github.com/stackitcloud/stackit-sdk-go/services/dns v0.23.1 h1:6hm7oYJeR5Bu8d+PeCvVLWjUXh78pSfdsCTZ7lu5qdc= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 9b419a617..55787fba1 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -11,6 +11,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -42,6 +43,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(volume.NewCmd(params)) cmd.AddCommand(sfs.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) cmd.AddCommand(albwaf.NewCmd(params)) diff --git a/internal/cmd/beta/volume/automation/automation.go b/internal/cmd/beta/volume/automation/automation.go new file mode 100644 index 000000000..3d6683647 --- /dev/null +++ b/internal/cmd/beta/volume/automation/automation.go @@ -0,0 +1,25 @@ +package automation + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/template" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "automation", + Short: "Provides functionality for Volume Automation", + Long: "Provides functionality for Volume Automation.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(template.NewCmd(params)) +} diff --git a/internal/cmd/beta/volume/automation/template/describe/describe.go b/internal/cmd/beta/volume/automation/template/describe/describe.go new file mode 100644 index 000000000..a097a81bf --- /dev/null +++ b/internal/cmd/beta/volume/automation/template/describe/describe.go @@ -0,0 +1,159 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/automation/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + templateIdArg = "TEMPLATE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + TemplateId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", templateIdArg), + Short: "Shows details of a Volume Automation Template", + Long: "Shows details of a Volume Automation Template.", + Args: args.SingleArg(templateIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe the Volume Automation Template with ID "xxx"`, + "$ stackit beta volume automation template describe xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("describe volume automation template: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, model.TemplateId, projectLabel, resp) + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiGetVolumeTemplateRequest { + req := apiClient.DefaultAPI.GetVolumeTemplate(ctx, model.ProjectId, model.Region, model.TemplateId) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + templateId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + TemplateId: templateId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, templateId, projectLabel string, template *automation.GetVolumeTemplateResponse) error { + return p.OutputResult(outputFormat, template, func() error { + if template == nil { + p.Outputf("Volume automation template %q not found in project %q\n", templateId, projectLabel) + return nil + } + + var content []tables.Table + + table := tables.NewTable() + + table.SetTitle("Volume Automation Template") + table.AddRow("ID", template.Id) + table.AddSeparator() + table.AddRow("NAME", template.Name) + table.AddSeparator() + table.AddRow("DESCRIPTION", template.Description) + table.AddSeparator() + if template.Input != nil { + table.AddRow("INPUT KIND", template.Input.Kind) + table.AddSeparator() + } + + content = append(content, table) + + if template.Output != nil && len(template.Output.Steps) > 0 { + outputStepsTable := tables.NewTable() + outputStepsTable.SetTitle("OUTPUT STEPS") + for _, step := range template.Output.Steps { + outputStepsTable.AddRow("NAME", step.Name) + outputStepsTable.AddSeparator() + if step.Result != nil { + if step.Result.GetVolumeIDsResult != nil { + volumeIds := "" + if len(step.Result.GetVolumeIDsResult.VolumeIDs) > 0 { + volumeIds = strings.Join(step.Result.GetVolumeIDsResult.VolumeIDs, "\n") + } + outputStepsTable.AddRow("VOLUME IDS", volumeIds) + outputStepsTable.AddSeparator() + } + if step.Result.CreateSnapshotsResult != nil { + createdSnapshotIds := "" + if len(step.Result.CreateSnapshotsResult.CreatedSnapshotIDs) > 0 { + createdSnapshotIds = strings.Join(step.Result.CreateSnapshotsResult.CreatedSnapshotIDs, "\n") + } + outputStepsTable.AddRow("CREATED SNAPSHOT IDS", createdSnapshotIds) + outputStepsTable.AddSeparator() + } + } + } + content = append(content, outputStepsTable) + } + + err := tables.DisplayTables(p, content) + if err != nil { + return fmt.Errorf("render tables: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/volume/automation/template/describe/describe_test.go b/internal/cmd/beta/volume/automation/template/describe/describe_test.go new file mode 100644 index 000000000..c8c00450f --- /dev/null +++ b/internal/cmd/beta/volume/automation/template/describe/describe_test.go @@ -0,0 +1,194 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &automation.APIClient{DefaultAPI: &automation.DefaultAPIService{}} + testProjectId = uuid.NewString() + testTemplateId = uuid.NewString() +) + +const testRegion = "eu01" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testTemplateId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + TemplateId: testTemplateId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiGetVolumeTemplateRequest)) automation.ApiGetVolumeTemplateRequest { + request := testClient.DefaultAPI.GetVolumeTemplate(testCtx, testProjectId, testRegion, testTemplateId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + expectedModel: nil, + }, + { + description: "missing args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: nil, + }, + { + description: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + expectedModel: nil, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest automation.ApiGetVolumeTemplateRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, automation.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + templateId string + projectLabel string + template *automation.GetVolumeTemplateResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty response", + args: args{ + templateId: testTemplateId, + projectLabel: testProjectId, + template: &automation.GetVolumeTemplateResponse{}, + }, + wantErr: false, + }, + { + name: "empty output field", + args: args{ + templateId: testTemplateId, + projectLabel: testProjectId, + template: &automation.GetVolumeTemplateResponse{ + Output: &automation.VolumeOutput{}, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.templateId, tt.args.projectLabel, tt.args.template); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/template/list/list.go b/internal/cmd/beta/volume/automation/template/list/list.go new file mode 100644 index 000000000..573259557 --- /dev/null +++ b/internal/cmd/beta/volume/automation/template/list/list.go @@ -0,0 +1,161 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/experimental/paginate" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/automation/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + limitFlag = "limit" + + // maxPageSize is the maximum number of items the API returns per page. + maxPageSize = 100 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all Volume Automation Templates", + Long: "List all Volume Automation Templates.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Volume Automation Templates`, + "$ stackit beta volume automation template list"), + examples.NewExample( + `List up to 10 Volume Automation Templates`, + "$ stackit beta volume automation template list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := fetchTemplates(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("list volume automation templates: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiListVolumeTemplatesRequest { + return apiClient.DefaultAPI.ListVolumeTemplates(ctx, model.ProjectId, model.Region) +} + +func fetchTemplates(ctx context.Context, model *inputModel, apiClient *automation.APIClient) ([]automation.Template, error) { + req := buildRequest(ctx, model, apiClient) + opts := []paginate.Option{paginate.WithPageSize(maxPageSize)} + if model.Limit != nil { + opts = append(opts, paginate.WithLimit(int(*model.Limit))) + } + + items, err := paginate.All[automation.Template](req, opts...) + if err != nil { + return nil, fmt.Errorf("list volume automation templates: %w", err) + } + if items == nil { + items = []automation.Template{} + } + + return items, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, templates []automation.Template) error { + return p.OutputResult(outputFormat, templates, func() error { + if len(templates) == 0 { + p.Outputf("No volume automation templates found in project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + + table.SetTitle("Volume Automation Templates") + table.SetHeader("ID", "NAME", "DESCRIPTION") + table.AddSeparator() + for _, template := range templates { + table.AddRow( + template.Id, + template.Name, + template.Description, + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render tables: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/volume/automation/template/list/list_test.go b/internal/cmd/beta/volume/automation/template/list/list_test.go new file mode 100644 index 000000000..ec486fd27 --- /dev/null +++ b/internal/cmd/beta/volume/automation/template/list/list_test.go @@ -0,0 +1,183 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &automation.APIClient{DefaultAPI: &automation.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +const ( + testLimit int64 = 10 + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: new(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiListVolumeTemplatesRequest)) automation.ApiListVolumeTemplatesRequest { + request := testClient.DefaultAPI.ListVolumeTemplates(testCtx, testProjectId, testRegion) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + expectedModel: nil, + }, + { + description: "missing project id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + expectedModel: nil, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest automation.ApiListVolumeTemplatesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, automation.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + templates []automation.Template + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "nil templates slice", + args: args{ + projectLabel: testProjectId, + templates: nil, + }, + wantErr: false, + }, + { + name: "empty templates slice", + args: args{ + projectLabel: testProjectId, + templates: []automation.Template{}, + }, + wantErr: false, + }, + { + name: "empty template struct", + args: args{ + projectLabel: testProjectId, + templates: []automation.Template{ + {}, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.templates); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/template/template.go b/internal/cmd/beta/volume/automation/template/template.go new file mode 100644 index 000000000..3d33ab380 --- /dev/null +++ b/internal/cmd/beta/volume/automation/template/template.go @@ -0,0 +1,27 @@ +package template + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/template/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/template/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Short: "Provides functionality for Volume Automation Templates", + Long: "Provides functionality for Volume Automation Templates.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/volume/volume.go b/internal/cmd/beta/volume/volume.go new file mode 100644 index 000000000..c9f2f6cf6 --- /dev/null +++ b/internal/cmd/beta/volume/volume.go @@ -0,0 +1,25 @@ +package volume + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "volume", + Short: "Provides functionality for Volume", + Long: "Provides functionality for Volume.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(automation.NewCmd(params)) +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 5401e4aa6..5a37ee68c 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -25,6 +25,7 @@ const ( allowedUrlDomainFlag = "allowed-url-domain" authorizationCustomEndpointFlag = "authorization-custom-endpoint" + automationCustomEndpointFlag = "automation-custom-endpoint" albWafCustomEndpointFlag = "alb-waf-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" edgeCustomEndpointFlag = "edge-custom-endpoint" @@ -147,6 +148,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(allowedUrlDomainFlag, "", `Domain name, used for the verification of the URLs that are given in the custom identity provider endpoint and "STACKIT curl" command`) cmd.Flags().String(observabilityCustomEndpointFlag, "", "Observability API base URL, used in calls to this API") cmd.Flags().String(authorizationCustomEndpointFlag, "", "Authorization API base URL, used in calls to this API") + cmd.Flags().String(automationCustomEndpointFlag, "", "Automation API base URL, used in calls to this API") cmd.Flags().String(albWafCustomEndpointFlag, "", "ALB WAF API base URL, used in calls to this API") cmd.Flags().String(dnsCustomEndpointFlag, "", "DNS API base URL, used in calls to this API") cmd.Flags().String(edgeCustomEndpointFlag, "", "Edge API base URL, used in calls to this API") @@ -190,6 +192,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.AuthorizationCustomEndpointKey, cmd.Flags().Lookup(authorizationCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.AutomationCustomEndpointKey, cmd.Flags().Lookup(automationCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.AlbWafCustomEndpointKey, cmd.Flags().Lookup(albWafCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.DNSCustomEndpointKey, cmd.Flags().Lookup(dnsCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 110204f64..ba128c62f 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -28,6 +28,7 @@ const ( allowedUrlDomainFlag = "allowed-url-domain" authorizationCustomEndpointFlag = "authorization-custom-endpoint" + automationCustomEndpointFlag = "automation-custom-endpoint" albWafCustomEndpointFlag = "alb-waf-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" edgeCustomEndpointFlag = "edge-custom-endpoint" @@ -79,6 +80,7 @@ type inputModel struct { AllowedUrlDomain bool AuthorizationCustomEndpoint bool + AutomationCustomEndpoint bool AlbWafCustomEndpoint bool DNSCustomEndpoint bool EdgeCustomEndpoint bool @@ -169,6 +171,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.AuthorizationCustomEndpoint { viper.Set(config.AuthorizationCustomEndpointKey, "") } + if model.AutomationCustomEndpoint { + viper.Set(config.AutomationCustomEndpointKey, "") + } if model.AlbWafCustomEndpoint { viper.Set(config.AlbWafCustomEndpointKey, "") } @@ -283,6 +288,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(observabilityCustomEndpointFlag, false, "Observability API base URL. If unset, uses the default base URL") cmd.Flags().Bool(authorizationCustomEndpointFlag, false, "Authorization API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(automationCustomEndpointFlag, false, "Automation API base URL. If unset, uses the default base URL") cmd.Flags().Bool(albWafCustomEndpointFlag, false, "ALB WAF API base URL. If unset, uses the default base URL") cmd.Flags().Bool(dnsCustomEndpointFlag, false, "DNS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(edgeCustomEndpointFlag, false, "Edge API base URL. If unset, uses the default base URL") @@ -329,6 +335,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { AllowedUrlDomain: flags.FlagToBoolValue(p, cmd, allowedUrlDomainFlag), AuthorizationCustomEndpoint: flags.FlagToBoolValue(p, cmd, authorizationCustomEndpointFlag), + AutomationCustomEndpoint: flags.FlagToBoolValue(p, cmd, automationCustomEndpointFlag), AlbWafCustomEndpoint: flags.FlagToBoolValue(p, cmd, albWafCustomEndpointFlag), DNSCustomEndpoint: flags.FlagToBoolValue(p, cmd, dnsCustomEndpointFlag), EdgeCustomEndpoint: flags.FlagToBoolValue(p, cmd, edgeCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 75965ec31..5ca73e03a 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -23,6 +23,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool allowedUrlDomainFlag: true, authorizationCustomEndpointFlag: true, + automationCustomEndpointFlag: true, albWafCustomEndpointFlag: true, dnsCustomEndpointFlag: true, edgeCustomEndpointFlag: true, @@ -71,6 +72,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { AllowedUrlDomain: true, AuthorizationCustomEndpoint: true, + AutomationCustomEndpoint: true, AlbWafCustomEndpoint: true, DNSCustomEndpoint: true, EdgeCustomEndpoint: true, @@ -135,6 +137,7 @@ func TestParseInput(t *testing.T) { model.AllowedUrlDomain = false model.AuthorizationCustomEndpoint = false + model.AutomationCustomEndpoint = false model.AlbWafCustomEndpoint = false model.DNSCustomEndpoint = false model.EdgeCustomEndpoint = false @@ -224,6 +227,16 @@ func TestParseInput(t *testing.T) { model.ObservabilityCustomEndpoint = false }), }, + { + description: "automation custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[automationCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.AutomationCustomEndpoint = false + }), + }, { description: "alb waf custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 2365ed8b4..b0ad97036 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -24,6 +24,7 @@ const ( AllowedUrlDomainKey = "allowed_url_domain" AuthorizationCustomEndpointKey = "authorization_custom_endpoint" + AutomationCustomEndpointKey = "automation_custom_endpoint" AlbCustomEndpoint = "alb_custom _endpoint" AlbWafCustomEndpointKey = "alb_waf_custom_endpoint" DNSCustomEndpointKey = "dns_custom_endpoint" @@ -97,6 +98,7 @@ var ConfigKeys = []string{ AlbCustomEndpoint, AlbWafCustomEndpointKey, AuthorizationCustomEndpointKey, + AutomationCustomEndpointKey, CDNCustomEndpointKey, DNSCustomEndpointKey, EdgeCustomEndpointKey, @@ -198,6 +200,7 @@ func setConfigDefaults() { viper.SetDefault(EdgeCustomEndpointKey, "") viper.SetDefault(ObservabilityCustomEndpointKey, "") viper.SetDefault(AuthorizationCustomEndpointKey, "") + viper.SetDefault(AutomationCustomEndpointKey, "") viper.SetDefault(MongoDBFlexCustomEndpointKey, "") viper.SetDefault(ObjectStorageCustomEndpointKey, "") viper.SetDefault(OpenSearchCustomEndpointKey, "") diff --git a/internal/pkg/services/automation/client/client.go b/internal/pkg/services/automation/client/client.go new file mode 100644 index 000000000..a4dc9832e --- /dev/null +++ b/internal/pkg/services/automation/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/spf13/viper" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*automation.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.AutomationCustomEndpointKey), false, automation.NewAPIClient) +}