diff --git a/docs/stackit_beta_volume_automation.md b/docs/stackit_beta_volume_automation.md index 4b6bdb75c..7dd58651b 100644 --- a/docs/stackit_beta_volume_automation.md +++ b/docs/stackit_beta_volume_automation.md @@ -30,6 +30,12 @@ stackit beta volume automation [flags] ### SEE ALSO * [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume +* [stackit beta volume automation create](./stackit_beta_volume_automation_create.md) - Creates a Volume Automation +* [stackit beta volume automation delete](./stackit_beta_volume_automation_delete.md) - Deletes a Volume Automation +* [stackit beta volume automation describe](./stackit_beta_volume_automation_describe.md) - Shows details of a Volume Automation * [stackit beta volume automation execution](./stackit_beta_volume_automation_execution.md) - Provides functionality for Volume Automation Execution +* [stackit beta volume automation generate-payload](./stackit_beta_volume_automation_generate-payload.md) - Generates a payload to update volume automation +* [stackit beta volume automation list](./stackit_beta_volume_automation_list.md) - List all Volume Automations * [stackit beta volume automation template](./stackit_beta_volume_automation_template.md) - Provides functionality for Volume Automation Templates +* [stackit beta volume automation update](./stackit_beta_volume_automation_update.md) - Updates a Volume Automation diff --git a/docs/stackit_beta_volume_automation_create.md b/docs/stackit_beta_volume_automation_create.md new file mode 100644 index 000000000..702b8bce8 --- /dev/null +++ b/docs/stackit_beta_volume_automation_create.md @@ -0,0 +1,54 @@ +## stackit beta volume automation create + +Creates a Volume Automation + +### Synopsis + +Creates a Volume Automation. +The input for the automation can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/CreateVolumeAutomation for information regarding the payload structure. + + +``` +stackit beta volume automation create [flags] +``` + +### Examples + +``` + Creates a Volume Automation with name "my-automation", template ID "xxx" and input from the file "./input-payload.json" + $ stackit beta volume automation create --name my-automation --template-id xxx --input @./input-payload.json + + Creates a Volume Automation with name "my-automation", description "CLI Example", template ID "xxx" and input from the file "./input-payload.json" + $ stackit beta volume automation create --name my-automation --description "CLI Example" --template-id xxx --input @./input-payload.json + + Creates a Volume Automation with name "my-automation", template ID "xxx", rrule schedule trigger "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" and input from the file "./input-payload.json" + $ stackit beta volume automation create --name my-automation --template-id xxx --trigger-schedule-rrule "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" --input @./input-payload.json +``` + +### Options + +``` + --description string Description of the automation + -h, --help Help for "stackit beta volume automation create" + --input string Input for the automation (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). + --name string Name of the automation + --template-id string Template ID which should be used for the automation + --trigger-schedule-rrule string Trigger schedule (RRULE) for the 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 automation](./stackit_beta_volume_automation.md) - Provides functionality for Volume Automation + diff --git a/docs/stackit_beta_volume_automation_delete.md b/docs/stackit_beta_volume_automation_delete.md new file mode 100644 index 000000000..c7480b381 --- /dev/null +++ b/docs/stackit_beta_volume_automation_delete.md @@ -0,0 +1,40 @@ +## stackit beta volume automation delete + +Deletes a Volume Automation + +### Synopsis + +Deletes a Volume Automation. + +``` +stackit beta volume automation delete AUTOMATION_ID [flags] +``` + +### Examples + +``` + Delete a Volume Automation with ID "xxx" + $ stackit beta volume automation delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation delete" +``` + +### 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 + diff --git a/docs/stackit_beta_volume_automation_describe.md b/docs/stackit_beta_volume_automation_describe.md new file mode 100644 index 000000000..ba1252d53 --- /dev/null +++ b/docs/stackit_beta_volume_automation_describe.md @@ -0,0 +1,40 @@ +## stackit beta volume automation describe + +Shows details of a Volume Automation + +### Synopsis + +Shows details of a Volume Automation. + +``` +stackit beta volume automation describe AUTOMATION_ID [flags] +``` + +### Examples + +``` + Describe the Volume Automation with ID "xxx" + $ stackit beta volume automation describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation 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](./stackit_beta_volume_automation.md) - Provides functionality for Volume Automation + diff --git a/docs/stackit_beta_volume_automation_generate-payload.md b/docs/stackit_beta_volume_automation_generate-payload.md new file mode 100644 index 000000000..26b164e56 --- /dev/null +++ b/docs/stackit_beta_volume_automation_generate-payload.md @@ -0,0 +1,48 @@ +## stackit beta volume automation generate-payload + +Generates a payload to update volume automation + +### Synopsis + +Generates a JSON payload with values to be used as --input for volume automation update. +See https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/PartialUpdateVolumeAutomation for information regarding the input structure. + +``` +stackit beta volume automation generate-payload [flags] +``` + +### Examples + +``` + Generate a payload with values of a volume automation, and adapt it with custom values to different configuration options. + $ stackit beta volume automation generate-payload --automation-id XXX --file-path ./input-payload.json + + $ stackit beta volume automation update xxx --input @./input-payload.json + + Generate a payload with values of a volume automation, and preview it in the terminal. + $ stackit beta volume automation generate-payload --automation-id XXX +``` + +### Options + +``` + --automation-id string Automation ID from which the input values should be used + -f, --file-path string If set, writes the payload to the given file. If unset, writes the payload to the standard output + -h, --help Help for "stackit beta volume automation generate-payload" +``` + +### 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 + diff --git a/docs/stackit_beta_volume_automation_list.md b/docs/stackit_beta_volume_automation_list.md new file mode 100644 index 000000000..a0c608889 --- /dev/null +++ b/docs/stackit_beta_volume_automation_list.md @@ -0,0 +1,44 @@ +## stackit beta volume automation list + +List all Volume Automations + +### Synopsis + +List all Volume Automations. + +``` +stackit beta volume automation list [flags] +``` + +### Examples + +``` + List all Volume Automations + $ stackit beta volume automation list + + List up to 10 Volume Automations + $ stackit beta volume automation list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta volume automation 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](./stackit_beta_volume_automation.md) - Provides functionality for Volume Automation + diff --git a/docs/stackit_beta_volume_automation_update.md b/docs/stackit_beta_volume_automation_update.md new file mode 100644 index 000000000..51f062b0c --- /dev/null +++ b/docs/stackit_beta_volume_automation_update.md @@ -0,0 +1,61 @@ +## stackit beta volume automation update + +Updates a Volume Automation + +### Synopsis + +Updates a Volume Automation. +The input for the automation can be provided as a JSON string or a file path prefixed with "@". +Updates are always applied as a patch. Omitted fields in the JSON will be ignored and remain unchanged after the update. Fields can be removed by setting them explicitly to null. +See https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/PartialUpdateVolumeAutomation for information regarding the payload structure. + + +``` +stackit beta volume automation update AUTOMATION_ID [flags] +``` + +### Examples + +``` + Update volume automation with ID "xxx" with input from the file "./input-payload.json" + $ stackit beta volume automation update xxx --input @./input-payload.json + + Update volume automation with ID "xxx" with name "my-updated-automation" + $ stackit beta volume automation update xxx --name "my-updated-automation" + + Update volume automation with ID "xxx" with description "Updated automation from STACKIT CLI" + $ stackit beta volume automation update xxx --description "Updated automation from STACKIT CLI" + + Update volume automation with ID "xxx" with disabling trigger schedule rrule + $ stackit beta volume automation update xxx --disable-trigger-schedule + + Update volume automation with ID "xxx" with trigger schedule rrule "FREQ=WEEKLY;BYDAY=MO" + $ stackit beta volume automation update xxx --trigger-schedule-rrule "FREQ=WEEKLY;BYDAY=MO" +``` + +### Options + +``` + --description string Description of the automation + --disable-trigger-schedule If set, trigger schedule will be disabled + -h, --help Help for "stackit beta volume automation update" + --input string Input for the automation (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). + --name string Name of the automation + --trigger-schedule-rrule string Trigger schedule (RRULE) for the 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 automation](./stackit_beta_volume_automation.md) - Provides functionality for Volume Automation + diff --git a/internal/cmd/beta/volume/automation/automation.go b/internal/cmd/beta/volume/automation/automation.go index 4be868bdd..fb8cd9d84 100644 --- a/internal/cmd/beta/volume/automation/automation.go +++ b/internal/cmd/beta/volume/automation/automation.go @@ -3,8 +3,14 @@ package automation import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/execution" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/template" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/automation/update" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -22,6 +28,12 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(execution.NewCmd(params)) + cmd.AddCommand(generatepayload.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(template.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/beta/volume/automation/create/create.go b/internal/cmd/beta/volume/automation/create/create.go new file mode 100644 index 000000000..45d7e1495 --- /dev/null +++ b/internal/cmd/beta/volume/automation/create/create.go @@ -0,0 +1,158 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" + + "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/types" +) + +const ( + templateIdFlag = "template-id" + nameFlag = "name" + descriptionFlag = "description" + inputFlag = "input" + triggerScheduleRruleFlag = "trigger-schedule-rrule" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + TemplateId string + Name *string + Description *string + Input *automation.VolumeAutomationInput + TriggerScheduleRrule *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Volume Automation", + Long: fmt.Sprintf("%s\n%s\n%s\n", + "Creates a Volume Automation.", + "The input for the automation can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/CreateVolumeAutomation for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Creates a Volume Automation with name "my-automation", template ID "xxx" and input from the file "./input-payload.json"`, + "$ stackit beta volume automation create --name my-automation --template-id xxx --input @./input-payload.json"), + examples.NewExample( + `Creates a Volume Automation with name "my-automation", description "CLI Example", template ID "xxx" and input from the file "./input-payload.json"`, + "$ stackit beta volume automation create --name my-automation --description \"CLI Example\" --template-id xxx --input @./input-payload.json"), + examples.NewExample( + `Creates a Volume Automation with name "my-automation", template ID "xxx", rrule schedule trigger "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" and input from the file "./input-payload.json"`, + "$ stackit beta volume automation create --name my-automation --template-id xxx --trigger-schedule-rrule \"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\" --input @./input-payload.json"), + ), + 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 + } + + 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 + } + + // Call API + createdAutomation, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("create volume automation: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, createdAutomation) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), templateIdFlag, "Template ID which should be used for the automation") + cmd.Flags().String(nameFlag, "", "Name of the automation") + cmd.Flags().String(descriptionFlag, "", "Description of the automation") + cmd.Flags().Var(flags.ReadFromFileFlag(), inputFlag, `Input for the automation (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json).`) + cmd.Flags().String(triggerScheduleRruleFlag, "", "Trigger schedule (RRULE) for the automation") + + err := flags.MarkFlagsRequired(cmd, templateIdFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiCreateVolumeAutomationRequest { + payload := automation.CreateVolumeAutomationPayload{ + Name: *automation.NewNullableString(model.Name), + Description: *automation.NewNullableString(model.Description), + Input: *automation.NewNullableVolumeAutomationInput(model.Input), + TemplateId: model.TemplateId, + } + if model.TriggerScheduleRrule != nil { + payload.Triggers = *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: *model.TriggerScheduleRrule, + }), + }) + } + return apiClient.DefaultAPI.CreateVolumeAutomation(ctx, model.ProjectId, model.Region).CreateVolumeAutomationPayload(payload) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + var inputConfig *automation.VolumeAutomationInput + if inputJson := flags.FlagToStringPointer(p, cmd, inputFlag); inputJson != nil { + inputConfig = &automation.VolumeAutomationInput{} + err := inputConfig.UnmarshalJSON([]byte(*inputJson)) + if err != nil { + return nil, fmt.Errorf("the input from --%s can not be parsed: %w", inputFlag, err) + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + TemplateId: flags.FlagToStringValue(p, cmd, templateIdFlag), + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Input: inputConfig, + TriggerScheduleRrule: flags.FlagToStringPointer(p, cmd, triggerScheduleRruleFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, item *automation.VolumeAutomation) error { + if item == nil { + return fmt.Errorf("volume automation response is empty") + } + return p.OutputResult(outputFormat, item, func() error { + p.Outputf("Created volume automation for project %q. Automation ID: %q\n", projectLabel, item.Id) + return nil + }) +} diff --git a/internal/cmd/beta/volume/automation/create/create_test.go b/internal/cmd/beta/volume/automation/create/create_test.go new file mode 100644 index 000000000..2bc0191a7 --- /dev/null +++ b/internal/cmd/beta/volume/automation/create/create_test.go @@ -0,0 +1,264 @@ +package create + +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/v1api" + + "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() + testAutomationId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testName = "my-automation" + testDescription = "my-description" + testTriggerScheduleRrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" + testInputJson = `{"kind": "VolumeRecoveryPointManagement"}` +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + templateIdFlag: testTemplateId, + nameFlag: testName, + descriptionFlag: testDescription, + triggerScheduleRruleFlag: testTriggerScheduleRrule, + inputFlag: testInputJson, + } + 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, + Name: new(testName), + Description: new(testDescription), + Input: &automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{}, + }, + TriggerScheduleRrule: new(testTriggerScheduleRrule), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiCreateVolumeAutomationRequest)) automation.ApiCreateVolumeAutomationRequest { + payload := automation.CreateVolumeAutomationPayload{ + Name: *automation.NewNullableString(new(testName)), + Description: *automation.NewNullableString(new(testDescription)), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{}, + }), + Triggers: *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + }), + }), + TemplateId: testTemplateId, + } + request := testClient.DefaultAPI.CreateVolumeAutomation(testCtx, testProjectId, testRegion).CreateVolumeAutomationPayload(payload) + 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: "only required flags", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + templateIdFlag: testTemplateId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + TemplateId: testTemplateId, + }, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing project id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "missing template id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, templateIdFlag) + }), + isValid: false, + }, + { + description: "template id is no uuid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[templateIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + { + description: "invalid input json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[inputFlag] = "invalid-json" + }), + isValid: false, + }, + } + 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.ApiCreateVolumeAutomationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "only required fields", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + TemplateId: testTemplateId, + }, + isValid: true, + expectedRequest: fixtureRequest(func(request *automation.ApiCreateVolumeAutomationRequest) { + payload := automation.CreateVolumeAutomationPayload{ + Name: *automation.NewNullableString(nil), + Description: *automation.NewNullableString(nil), + Input: *automation.NewNullableVolumeAutomationInput(nil), + TemplateId: testTemplateId, + Triggers: automation.NullableAutomationTriggers{}, + } + *request = testClient.DefaultAPI.CreateVolumeAutomation(testCtx, testProjectId, testRegion).CreateVolumeAutomationPayload(payload) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported( + tt.expectedRequest, + automation.DefaultAPIService{}, + automation.NullableString{}, + automation.NullableVolumeAutomationInput{}, + automation.NullableAutomationTriggers{}, + automation.NullableAutomationScheduleTrigger{}, + ), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match (-want, +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + item *automation.VolumeAutomation + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil item", + args: args{ + projectLabel: testProjectId, + item: nil, + }, + wantErr: true, + }, + { + name: "valid response", + args: args{ + projectLabel: testProjectId, + item: &automation.VolumeAutomation{ + Id: testAutomationId, + }, + }, + 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.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/delete/delete.go b/internal/cmd/beta/volume/automation/delete/delete.go new file mode 100644 index 000000000..98422cde8 --- /dev/null +++ b/internal/cmd/beta/volume/automation/delete/delete.go @@ -0,0 +1,103 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" + + "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/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + automationIdArg = "AUTOMATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AutomationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", automationIdArg), + Short: "Deletes a Volume Automation", + Long: "Deletes a Volume Automation.", + Args: args.SingleArg(automationIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a Volume Automation with ID "xxx"`, + "$ stackit beta volume automation delete 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 + } + + 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 + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete volume automation %q in project %q?", model.AutomationId, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + err = buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("delete volume automation: %w", err) + } + + params.Printer.Outputf("Deleted volume automation %q\n", model.AutomationId) + return nil + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiDeleteVolumeAutomationRequest { + return apiClient.DefaultAPI.DeleteVolumeAutomation(ctx, model.ProjectId, model.Region, model.AutomationId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + automationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AutomationId: automationId, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/beta/volume/automation/delete/delete_test.go b/internal/cmd/beta/volume/automation/delete/delete_test.go new file mode 100644 index 000000000..e6f76abfa --- /dev/null +++ b/internal/cmd/beta/volume/automation/delete/delete_test.go @@ -0,0 +1,159 @@ +package delete + +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/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "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() + testAutomationId = uuid.NewString() +) + +const testRegion = "eu01" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAutomationId, + } + 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, + }, + AutomationId: testAutomationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiDeleteVolumeAutomationRequest)) automation.ApiDeleteVolumeAutomationRequest { + request := testClient.DefaultAPI.DeleteVolumeAutomation(testCtx, testProjectId, testRegion, testAutomationId) + 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: "with assume yes", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.AssumeYesFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.AssumeYes = true + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + expectedModel: nil, + }, + { + description: "missing args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "arg is no uuid", + argValues: []string{"not-a-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + 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.ApiDeleteVolumeAutomationRequest + }{ + { + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest, automation.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match (-want, +got): %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/describe/describe.go b/internal/cmd/beta/volume/automation/describe/describe.go new file mode 100644 index 000000000..d5b039e21 --- /dev/null +++ b/internal/cmd/beta/volume/automation/describe/describe.go @@ -0,0 +1,179 @@ +package describe + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" + + "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 ( + automationIdArg = "AUTOMATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AutomationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", automationIdArg), + Short: "Shows details of a Volume Automation", + Long: "Shows details of a Volume Automation.", + Args: args.SingleArg(automationIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe the Volume Automation with ID "xxx"`, + "$ stackit beta volume automation 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: %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.AutomationId, projectLabel, resp) + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiGetVolumeAutomationRequest { + return apiClient.DefaultAPI.GetVolumeAutomation(ctx, model.ProjectId, model.Region, model.AutomationId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + automationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AutomationId: automationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, automationId, projectLabel string, item *automation.VolumeAutomation) error { + return p.OutputResult(outputFormat, item, func() error { + if item == nil { + p.Outputf("Volume automation %q not found in project %q\n", automationId, projectLabel) + return nil + } + + var content []tables.Table + + table := tables.NewTable() + table.SetTitle("Volume Automation") + + table.AddRow("ID", item.Id) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(item.Name)) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(item.Description)) + table.AddSeparator() + table.AddRow("TEMPLATE ID", utils.PtrString(item.TemplateId)) + table.AddSeparator() + table.AddRow("CREATE TIME", item.CreateTime.Format(time.DateTime)) + table.AddSeparator() + table.AddRow("UPDATE TIME", item.UpdateTime.Format(time.DateTime)) + table.AddSeparator() + + content = append(content, table) + + if input := item.Input.Get(); input != nil { + inputTable := tables.NewTable() + inputTable.SetTitle("Input") + + inputTable.AddRow("KIND", input.Kind) + inputTable.AddSeparator() + for key, value := range utils.FlattenMap(input.AdditionalProperties) { + inputTable.AddRow(key, value) + inputTable.AddSeparator() + } + + content = append(content, inputTable) + } + + if triggers := item.Triggers.Get(); triggers != nil { + // As long only schedule with rrule exists as trigger, the whole table should be printed only when rrule is set + if schedule := triggers.Schedule.Get(); schedule != nil { + triggersTable := tables.NewTable() + triggersTable.SetTitle("Triggers") + triggersTable.AddRow("SCHEDULE - RRULE", schedule.Rrule) + triggersTable.AddSeparator() + + content = append(content, triggersTable) + } + } + + if item.Output != nil && len(item.Output.Steps) > 0 { + outputStepsTable := tables.NewTable() + outputStepsTable.SetTitle("Output Steps") + outputStepsTable.SetHeader("NAME", "KIND", "DETAILS") + for _, step := range item.Output.Steps { + var kind, details string + if step.Result != nil { + kind = step.Result.Kind + // step.Result is an open object, so we need to read here the AdditionalProperties + for key, value := range utils.FlattenMap(step.Result.AdditionalProperties) { + details += fmt.Sprintf("%s: %v\n", key, value) + } + } + + outputStepsTable.AddRow(step.Name, kind, details) + outputStepsTable.AddSeparator() + } + + content = append(content, outputStepsTable) + } + + err := tables.DisplayTables(p, content) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/volume/automation/describe/describe_test.go b/internal/cmd/beta/volume/automation/describe/describe_test.go new file mode 100644 index 000000000..304d9f400 --- /dev/null +++ b/internal/cmd/beta/volume/automation/describe/describe_test.go @@ -0,0 +1,191 @@ +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/v1api" + + "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() + testAutomationId = uuid.NewString() +) + +const testRegion = "eu01" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAutomationId, + } + 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, + }, + AutomationId: testAutomationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiGetVolumeAutomationRequest)) automation.ApiGetVolumeAutomationRequest { + request := testClient.DefaultAPI.GetVolumeAutomation(testCtx, testProjectId, testRegion, testAutomationId) + 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, + }, + { + description: "arg is no uuid", + argValues: []string{"not-a-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + 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.ApiGetVolumeAutomationRequest + }{ + { + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest, automation.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match (-want, +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + automationId string + projectLabel string + item *automation.VolumeAutomation + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil item", + args: args{ + automationId: testAutomationId, + projectLabel: testProjectId, + item: nil, + }, + wantErr: false, + }, + { + name: "empty response", + args: args{ + automationId: testAutomationId, + projectLabel: testProjectId, + item: &automation.VolumeAutomation{}, + }, + 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.automationId, tt.args.projectLabel, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/generate-payload/generate_payload.go b/internal/cmd/beta/volume/automation/generate-payload/generate_payload.go new file mode 100644 index 000000000..e574fd093 --- /dev/null +++ b/internal/cmd/beta/volume/automation/generate-payload/generate_payload.go @@ -0,0 +1,131 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" + + "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/fileutils" + "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/services/automation/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + automationIdFlag = "automation-id" + filePathFlag = "file-path" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AutomationId string + FilePath *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to update volume automation", + Long: fmt.Sprintf("%s\n%s", + "Generates a JSON payload with values to be used as --input for volume automation update.", + "See https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/PartialUpdateVolumeAutomation for information regarding the input structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a payload with values of a volume automation, and adapt it with custom values to different configuration options.`, + "$ stackit beta volume automation generate-payload --automation-id XXX --file-path ./input-payload.json", + "", + "$ stackit beta volume automation update xxx --input @./input-payload.json"), + examples.NewExample( + `Generate a payload with values of a volume automation, and preview it in the terminal.`, + "$ stackit beta volume automation generate-payload --automation-id 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("get volume automation: %w", err) + } + + return outputResult(params.Printer, model.FilePath, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), automationIdFlag, "Automation ID from which the input values should be used") + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") + + err := flags.MarkFlagsRequired(cmd, automationIdFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiGetVolumeAutomationRequest { + return apiClient.DefaultAPI.GetVolumeAutomation(ctx, model.ProjectId, model.Region, model.AutomationId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AutomationId: flags.FlagToStringValue(p, cmd, automationIdFlag), + FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, filePath *string, item *automation.VolumeAutomation) error { + if item == nil { + return fmt.Errorf("payload is nil") + } + + if !item.Input.IsSet() || item.Input.Get() == nil { + return fmt.Errorf("volume automation %q does not have an input configuration", item.Id) + } + + inputBytes, err := json.MarshalIndent(item.Input, "", " ") + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + if filePath != nil { + err = fileutils.WriteToFile(*filePath, string(inputBytes)) + if err != nil { + return fmt.Errorf("write payload to the file: %w", err) + } + } else { + p.Outputln(string(inputBytes)) + } + + return nil +} diff --git a/internal/cmd/beta/volume/automation/generate-payload/generate_payload_test.go b/internal/cmd/beta/volume/automation/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..1fdc724ea --- /dev/null +++ b/internal/cmd/beta/volume/automation/generate-payload/generate_payload_test.go @@ -0,0 +1,221 @@ +package generatepayload + +import ( + "context" + "os" + "path/filepath" + "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/v1api" + + "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() + testAutomationId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testFilePath = "./input-payload.json" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + automationIdFlag: testAutomationId, + filePathFlag: testFilePath, + } + 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, + }, + AutomationId: testAutomationId, + FilePath: new(testFilePath), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiGetVolumeAutomationRequest)) automation.ApiGetVolumeAutomationRequest { + request := testClient.DefaultAPI.GetVolumeAutomation(testCtx, testProjectId, testRegion, testAutomationId) + 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 file path", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, filePathFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.FilePath = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + expectedModel: nil, + }, + { + description: "missing automation id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, automationIdFlag) + }), + isValid: false, + expectedModel: nil, + }, + { + description: "automation id is no uuid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[automationIdFlag] = "not-a-uuid" + }), + 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.ApiGetVolumeAutomationRequest + }{ + { + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest, automation.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match (-want, +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tempDir := t.TempDir() + type args struct { + filePath *string + item *automation.VolumeAutomation + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil item", + args: args{ + filePath: nil, + item: nil, + }, + wantErr: true, + }, + { + name: "terminal output", + args: args{ + filePath: nil, + item: &automation.VolumeAutomation{ + Id: testAutomationId, + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + }), + }, + }, + wantErr: false, + }, + { + name: "write to file", + args: args{ + filePath: new(filepath.Join(tempDir, "payload.json")), + item: &automation.VolumeAutomation{ + Id: testAutomationId, + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + }), + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.filePath, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.args.filePath != nil && !tt.wantErr { + if _, err := os.Stat(*tt.args.filePath); os.IsNotExist(err) { + t.Errorf("expected file %s to be created", *tt.args.filePath) + } + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/list/list.go b/internal/cmd/beta/volume/automation/list/list.go new file mode 100644 index 000000000..b42802fa6 --- /dev/null +++ b/internal/cmd/beta/volume/automation/list/list.go @@ -0,0 +1,164 @@ +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/v1api" + + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" + + 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 Automations", + Long: "List all Volume Automations.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Volume Automations`, + "$ stackit beta volume automation list"), + examples.NewExample( + `List up to 10 Volume Automations`, + "$ stackit beta volume automation 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 := fetchAutomations(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("list volume automations: %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.ApiListVolumeAutomationsRequest { + req := apiClient.DefaultAPI.ListVolumeAutomations(ctx, model.ProjectId, model.Region) + return req +} + +func fetchAutomations(ctx context.Context, model *inputModel, apiClient *automation.APIClient) ([]automation.ListAutomationsItem, 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.ListAutomationsItem](req, opts...) + if err != nil { + return nil, err + } + + if items == nil { + items = []automation.ListAutomationsItem{} + } + + return items, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, items []automation.ListAutomationsItem) error { + return p.OutputResult(outputFormat, items, func() error { + if len(items) == 0 { + p.Outputf("No volume automations found in project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "TEMPLATE ID") + + for _, item := range items { + table.AddRow( + item.Id, + utils.PtrString(item.Name), + utils.PtrString(item.Description), + utils.PtrString(item.TemplateId), + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/volume/automation/list/list_test.go b/internal/cmd/beta/volume/automation/list/list_test.go new file mode 100644 index 000000000..9e2f34c3e --- /dev/null +++ b/internal/cmd/beta/volume/automation/list/list_test.go @@ -0,0 +1,193 @@ +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/v1api" + + "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.ApiListVolumeAutomationsRequest)) automation.ApiListVolumeAutomationsRequest { + request := testClient.DefaultAPI.ListVolumeAutomations(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 limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + 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, + }, + { + description: "invalid limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + 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.ApiListVolumeAutomationsRequest + }{ + { + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest, automation.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match (-want, +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + items []automation.ListAutomationsItem + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty list", + args: args{ + projectLabel: testProjectId, + items: []automation.ListAutomationsItem{}, + }, + wantErr: false, + }, + { + name: "items list", + args: args{ + projectLabel: testProjectId, + items: []automation.ListAutomationsItem{ + { + Id: uuid.NewString(), + Name: new("my-automation"), + Description: new("my-description"), + TemplateId: new(uuid.NewString()), + }, + }, + }, + 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.items); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/volume/automation/update/update.go b/internal/cmd/beta/volume/automation/update/update.go new file mode 100644 index 000000000..fe3158ad6 --- /dev/null +++ b/internal/cmd/beta/volume/automation/update/update.go @@ -0,0 +1,182 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" + + "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/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + automationIdArg = "AUTOMATION_ID" + + nameFlag = "name" + descriptionFlag = "description" + inputFlag = "input" + triggerScheduleRruleFlag = "trigger-schedule-rrule" + disableTriggerSchedule = "disable-trigger-schedule" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AutomationId string + Name *string + Description *string + Input *automation.VolumeAutomationInput + TriggerScheduleRrule *string + DisableTriggerSchedule *bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", automationIdArg), + Short: "Updates a Volume Automation", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n", + "Updates a Volume Automation.", + "The input for the automation can be provided as a JSON string or a file path prefixed with \"@\".", + "Updates are always applied as a patch. Omitted fields in the JSON will be ignored and remain unchanged after the update. Fields can be removed by setting them explicitly to null.", + "See https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/PartialUpdateVolumeAutomation for information regarding the payload structure.", + ), + Args: args.SingleArg(automationIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update volume automation with ID "xxx" with input from the file "./input-payload.json"`, + "$ stackit beta volume automation update xxx --input @./input-payload.json"), + examples.NewExample( + `Update volume automation with ID "xxx" with name "my-updated-automation"`, + "$ stackit beta volume automation update xxx --name \"my-updated-automation\""), + examples.NewExample( + `Update volume automation with ID "xxx" with description "Updated automation from STACKIT CLI"`, + "$ stackit beta volume automation update xxx --description \"Updated automation from STACKIT CLI\""), + examples.NewExample( + `Update volume automation with ID "xxx" with disabling trigger schedule rrule`, + "$ stackit beta volume automation update xxx --disable-trigger-schedule"), + examples.NewExample( + `Update volume automation with ID "xxx" with trigger schedule rrule "FREQ=WEEKLY;BYDAY=MO"`, + "$ stackit beta volume automation update xxx --trigger-schedule-rrule \"FREQ=WEEKLY;BYDAY=MO\""), + ), + 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 + } + + 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 + } + + // Call API + updatedAutomation, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("update volume automation: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, updatedAutomation) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the automation") + cmd.Flags().String(descriptionFlag, "", "Description of the automation") + cmd.Flags().Var(flags.ReadFromFileFlag(), inputFlag, `Input for the automation (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json).`) + cmd.Flags().String(triggerScheduleRruleFlag, "", "Trigger schedule (RRULE) for the automation") + cmd.Flags().Bool(disableTriggerSchedule, false, "If set, trigger schedule will be disabled") + + cmd.MarkFlagsOneRequired(nameFlag, descriptionFlag, inputFlag, triggerScheduleRruleFlag, disableTriggerSchedule) + cmd.MarkFlagsMutuallyExclusive(triggerScheduleRruleFlag, disableTriggerSchedule) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *automation.APIClient) automation.ApiPartialUpdateVolumeAutomationRequest { + // The update payload fields are nullable, therefore we need to check if they are set in the model. + // Otherwise, the NullableString would be potentially set with nil as value, and they would be unwanted removed in the update + payload := automation.PartialUpdateVolumeAutomationPayload{} + if model.Name != nil { + payload.Name = *automation.NewNullableString(model.Name) + } + if model.Description != nil { + payload.Description = *automation.NewNullableString(model.Description) + } + if model.Input != nil { + payload.Input = *automation.NewNullableVolumeAutomationInput(model.Input) + } + if model.DisableTriggerSchedule != nil && *model.DisableTriggerSchedule { + payload.Triggers = *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(nil), + }) + } else if model.TriggerScheduleRrule != nil { + payload.Triggers = *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: *model.TriggerScheduleRrule, + }), + }) + } + return apiClient.DefaultAPI.PartialUpdateVolumeAutomation(ctx, model.ProjectId, model.Region, model.AutomationId).PartialUpdateVolumeAutomationPayload(payload) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + automationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + var inputConfig *automation.VolumeAutomationInput + if inputJson := flags.FlagToStringPointer(p, cmd, inputFlag); inputJson != nil { + inputConfig = &automation.VolumeAutomationInput{} + err := inputConfig.UnmarshalJSON([]byte(*inputJson)) + if err != nil { + return nil, fmt.Errorf("the input from --%s can not be parsed: %w", inputFlag, err) + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AutomationId: automationId, + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Input: inputConfig, + TriggerScheduleRrule: flags.FlagToStringPointer(p, cmd, triggerScheduleRruleFlag), + DisableTriggerSchedule: flags.FlagToBoolPointer(p, cmd, disableTriggerSchedule), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, item *automation.VolumeAutomation) error { + if item == nil { + return fmt.Errorf("volume automation response is empty") + } + return p.OutputResult(outputFormat, item, func() error { + p.Outputf("Updated volume automation for project %q. Automation ID: %q\n", projectLabel, item.Id) + return nil + }) +} diff --git a/internal/cmd/beta/volume/automation/update/update_test.go b/internal/cmd/beta/volume/automation/update/update_test.go new file mode 100644 index 000000000..ecc4e4f9c --- /dev/null +++ b/internal/cmd/beta/volume/automation/update/update_test.go @@ -0,0 +1,325 @@ +package update + +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/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &automation.APIClient{DefaultAPI: &automation.DefaultAPIService{}} + testProjectId = uuid.NewString() + testAutomationId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testName = "my-automation" + testDescription = "my-description" + testTriggerScheduleRrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" + testInputJson = `{"kind": "VolumeRecoveryPointManagement"}` +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAutomationId, + } + 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, + nameFlag: testName, + descriptionFlag: testDescription, + triggerScheduleRruleFlag: testTriggerScheduleRrule, + inputFlag: testInputJson, + } + 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, + }, + AutomationId: testAutomationId, + Name: new(testName), + Description: new(testDescription), + Input: &automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{}, + }, + TriggerScheduleRrule: new(testTriggerScheduleRrule), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *automation.ApiPartialUpdateVolumeAutomationRequest)) automation.ApiPartialUpdateVolumeAutomationRequest { + payload := automation.PartialUpdateVolumeAutomationPayload{ + Name: *automation.NewNullableString(new(testName)), + Description: *automation.NewNullableString(new(testDescription)), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{}, + }), + Triggers: *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: testTriggerScheduleRrule, + }), + }), + } + request := testClient.DefaultAPI.PartialUpdateVolumeAutomation(testCtx, testProjectId, testRegion, testAutomationId).PartialUpdateVolumeAutomationPayload(payload) + 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: "disable trigger schedule", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, triggerScheduleRruleFlag) + flagValues[disableTriggerSchedule] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.TriggerScheduleRrule = nil + model.DisableTriggerSchedule = utils.Ptr(true) + }), + }, + { + description: "only name flag", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: testName, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + AutomationId: testAutomationId, + Name: new(testName), + }, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no update flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + }, + { + description: "mutually exclusive trigger flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableTriggerSchedule] = "true" + }), + isValid: false, + }, + { + description: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "missing automation id arg", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "automation id is no uuid", + argValues: []string{"not-a-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid input json", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[inputFlag] = "invalid-json" + }), + isValid: false, + }, + } + 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.ApiPartialUpdateVolumeAutomationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "disable trigger schedule", + model: fixtureInputModel(func(model *inputModel) { + model.TriggerScheduleRrule = nil + model.DisableTriggerSchedule = utils.Ptr(true) + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *automation.ApiPartialUpdateVolumeAutomationRequest) { + payload := automation.PartialUpdateVolumeAutomationPayload{ + Name: *automation.NewNullableString(new(testName)), + Description: *automation.NewNullableString(new(testDescription)), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{}, + }), + Triggers: *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(nil), + }), + } + *request = testClient.DefaultAPI.PartialUpdateVolumeAutomation(testCtx, testProjectId, testRegion, testAutomationId).PartialUpdateVolumeAutomationPayload(payload) + }), + }, + { + description: "only name field", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + AutomationId: testAutomationId, + Name: new(testName), + }, + isValid: true, + expectedRequest: fixtureRequest(func(request *automation.ApiPartialUpdateVolumeAutomationRequest) { + payload := automation.PartialUpdateVolumeAutomationPayload{ + Name: *automation.NewNullableString(new(testName)), + } + *request = testClient.DefaultAPI.PartialUpdateVolumeAutomation(testCtx, testProjectId, testRegion, testAutomationId).PartialUpdateVolumeAutomationPayload(payload) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported( + tt.expectedRequest, + automation.DefaultAPIService{}, + automation.NullableString{}, + automation.NullableVolumeAutomationInput{}, + automation.NullableAutomationTriggers{}, + automation.NullableAutomationScheduleTrigger{}, + ), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match (-want, +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + item *automation.VolumeAutomation + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil item", + args: args{ + projectLabel: testProjectId, + item: nil, + }, + wantErr: true, + }, + { + name: "valid response", + args: args{ + projectLabel: testProjectId, + item: &automation.VolumeAutomation{ + Id: testAutomationId, + }, + }, + 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.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}