From 2cab8b483aa5027b14c7f7407a6778197ccca133 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Mon, 10 Feb 2025 14:56:22 +0100 Subject: [PATCH 1/4] Onboard affinity-group command - create - delete AFFINITY_GROUP_ID - describe AFFINITY_GROUP_ID - list --- docs/stackit_beta.md | 1 + docs/stackit_beta_affinity-group.md | 37 +++ docs/stackit_beta_affinity-group_create.md | 42 ++++ docs/stackit_beta_affinity-group_delete.md | 40 +++ docs/stackit_beta_affinity-group_describe.md | 40 +++ docs/stackit_beta_affinity-group_list.md | 44 ++++ docs/stackit_beta_image_update.md | 41 ++-- docs/stackit_beta_quota_list.md | 2 +- .../beta/affinity-groups/affinity-groups.go | 33 +++ .../cmd/beta/affinity-groups/create/create.go | 148 +++++++++++ .../affinity-groups/create/create_test.go | 232 ++++++++++++++++++ .../cmd/beta/affinity-groups/delete/delete.go | 113 +++++++++ .../affinity-groups/delete/delete_test.go | 176 +++++++++++++ .../beta/affinity-groups/describe/describe.go | 141 +++++++++++ .../affinity-groups/describe/describe_test.go | 212 ++++++++++++++++ .../cmd/beta/affinity-groups/list/list.go | 155 ++++++++++++ .../beta/affinity-groups/list/list_test.go | 206 ++++++++++++++++ internal/cmd/beta/beta.go | 4 +- internal/pkg/services/iaas/utils/utils.go | 12 + .../pkg/services/iaas/utils/utils_test.go | 54 ++++ 20 files changed, 1710 insertions(+), 23 deletions(-) create mode 100644 docs/stackit_beta_affinity-group.md create mode 100644 docs/stackit_beta_affinity-group_create.md create mode 100644 docs/stackit_beta_affinity-group_delete.md create mode 100644 docs/stackit_beta_affinity-group_describe.md create mode 100644 docs/stackit_beta_affinity-group_list.md create mode 100644 internal/cmd/beta/affinity-groups/affinity-groups.go create mode 100644 internal/cmd/beta/affinity-groups/create/create.go create mode 100644 internal/cmd/beta/affinity-groups/create/create_test.go create mode 100644 internal/cmd/beta/affinity-groups/delete/delete.go create mode 100644 internal/cmd/beta/affinity-groups/delete/delete_test.go create mode 100644 internal/cmd/beta/affinity-groups/describe/describe.go create mode 100644 internal/cmd/beta/affinity-groups/describe/describe_test.go create mode 100644 internal/cmd/beta/affinity-groups/list/list.go create mode 100644 internal/cmd/beta/affinity-groups/list/list_test.go diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index fde3ac259..9cbcd94d0 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -41,6 +41,7 @@ stackit beta [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit beta affinity-group](./stackit_beta_affinity-group.md) - Manage server affinity groups * [stackit beta image](./stackit_beta_image.md) - Manage server images * [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs * [stackit beta network](./stackit_beta_network.md) - Provides functionality for networks diff --git a/docs/stackit_beta_affinity-group.md b/docs/stackit_beta_affinity-group.md new file mode 100644 index 000000000..fb00bb0e0 --- /dev/null +++ b/docs/stackit_beta_affinity-group.md @@ -0,0 +1,37 @@ +## stackit beta affinity-group + +Manage server affinity groups + +### Synopsis + +Manage the lifecycle of server affinity groups. + +``` +stackit beta affinity-group [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta affinity-group" +``` + +### 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 affinity-group create](./stackit_beta_affinity-group_create.md) - Create affinity groups +* [stackit beta affinity-group delete](./stackit_beta_affinity-group_delete.md) - Delete affinity group +* [stackit beta affinity-group describe](./stackit_beta_affinity-group_describe.md) - Describes affinity group +* [stackit beta affinity-group list](./stackit_beta_affinity-group_list.md) - Lists affinity groups + diff --git a/docs/stackit_beta_affinity-group_create.md b/docs/stackit_beta_affinity-group_create.md new file mode 100644 index 000000000..c758d1d8f --- /dev/null +++ b/docs/stackit_beta_affinity-group_create.md @@ -0,0 +1,42 @@ +## stackit beta affinity-group create + +Create affinity groups + +### Synopsis + +Create affinity groups. + +``` +stackit beta affinity-group create [flags] +``` + +### Examples + +``` + Create an affinity group with name "AFFINITY_GROUP_NAME" and policy "soft-affinity" + $ stackit beta affinity-group create --name AFFINITY_GROUP_NAME --policy soft-affinity +``` + +### Options + +``` + -h, --help Help for "stackit beta affinity-group create" + --name string The name of the affinity group. + --policy string The policy for the affinity group. Valid values for the policy are: "hard-affinity", "hard-anti-affinity", "soft-affinity", "soft-anti-affinity" +``` + +### 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 affinity-group](./stackit_beta_affinity-group.md) - Manage server affinity groups + diff --git a/docs/stackit_beta_affinity-group_delete.md b/docs/stackit_beta_affinity-group_delete.md new file mode 100644 index 000000000..3d41e5126 --- /dev/null +++ b/docs/stackit_beta_affinity-group_delete.md @@ -0,0 +1,40 @@ +## stackit beta affinity-group delete + +Delete affinity group + +### Synopsis + +Delete affinity group. + +``` +stackit beta affinity-group delete AFFINITY_GROUP [flags] +``` + +### Examples + +``` + Delete an affinity group with ID "xxx" + $ stackit beta affinity-group delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta affinity-group 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 affinity-group](./stackit_beta_affinity-group.md) - Manage server affinity groups + diff --git a/docs/stackit_beta_affinity-group_describe.md b/docs/stackit_beta_affinity-group_describe.md new file mode 100644 index 000000000..accb956e6 --- /dev/null +++ b/docs/stackit_beta_affinity-group_describe.md @@ -0,0 +1,40 @@ +## stackit beta affinity-group describe + +Describes affinity group + +### Synopsis + +Describes affinity group by it's ID. + +``` +stackit beta affinity-group describe AFFINITY_GROUP_ID [flags] +``` + +### Examples + +``` + Get details about an affinity group with the ID "xxx" + $ stackit beta affinity-group describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta affinity-group 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 affinity-group](./stackit_beta_affinity-group.md) - Manage server affinity groups + diff --git a/docs/stackit_beta_affinity-group_list.md b/docs/stackit_beta_affinity-group_list.md new file mode 100644 index 000000000..059d0aeaf --- /dev/null +++ b/docs/stackit_beta_affinity-group_list.md @@ -0,0 +1,44 @@ +## stackit beta affinity-group list + +Lists affinity groups + +### Synopsis + +Lists affinity groups. + +``` +stackit beta affinity-group list [flags] +``` + +### Examples + +``` + List all affinity groups + $ stackit beta affinity-group list + + List the first 5 affinity groups + $ stackit beta affinity-group list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit beta affinity-group list" + --limit int Limit the output to the first n elements +``` + +### 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 affinity-group](./stackit_beta_affinity-group.md) - Manage server affinity groups + diff --git a/docs/stackit_beta_image_update.md b/docs/stackit_beta_image_update.md index 760d561de..c8355b0bb 100644 --- a/docs/stackit_beta_image_update.md +++ b/docs/stackit_beta_image_update.md @@ -23,27 +23,26 @@ stackit beta image update IMAGE_ID [flags] ### Options ``` - --boot-menu Enables the BIOS bootmenu. - --cdrom-bus string Sets CDROM bus controller type. - --disk-bus string Sets Disk bus controller type. - --disk-format string The disk format of the image. - -h, --help Help for "stackit beta image update" - --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) - --local-file-path string The path to the local disk image file. - --min-disk-size int Size in Gigabyte. - --min-ram int Size in Megabyte. - --name string The name of the image. - --nic-model string Sets virtual nic model. - --os string Enables OS specific optimizations. - --os-distro string Operating System Distribution. - --os-version string Version of the OS. - --protected Protected VM. - --rescue-bus string Sets the device bus when the image is used as a rescue image. - --rescue-device string Sets the device when the image is used as a rescue image. - --secure-boot Enables Secure Boot. - --uefi Enables UEFI boot. - --video-model string Sets Graphic device model. - --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. + --boot-menu Enables the BIOS bootmenu. + --cdrom-bus string Sets CDROM bus controller type. + --disk-bus string Sets Disk bus controller type. + --disk-format string The disk format of the image. + -h, --help Help for "stackit beta image update" + --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) + --min-disk-size int Size in Gigabyte. + --min-ram int Size in Megabyte. + --name string The name of the image. + --nic-model string Sets virtual nic model. + --os string Enables OS specific optimizations. + --os-distro string Operating System Distribution. + --os-version string Version of the OS. + --protected Protected VM. + --rescue-bus string Sets the device bus when the image is used as a rescue image. + --rescue-device string Sets the device when the image is used as a rescue image. + --secure-boot Enables Secure Boot. + --uefi Enables UEFI boot. + --video-model string Sets Graphic device model. + --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. ``` ### Options inherited from parent commands diff --git a/docs/stackit_beta_quota_list.md b/docs/stackit_beta_quota_list.md index f9a6fa62c..13203ab3d 100644 --- a/docs/stackit_beta_quota_list.md +++ b/docs/stackit_beta_quota_list.md @@ -4,7 +4,7 @@ Lists quotas ### Synopsis -Lists server quotas. +Lists project quotas. ``` stackit beta quota list [flags] diff --git a/internal/cmd/beta/affinity-groups/affinity-groups.go b/internal/cmd/beta/affinity-groups/affinity-groups.go new file mode 100644 index 000000000..f05389de3 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/affinity-groups.go @@ -0,0 +1,33 @@ +package affinity_groups + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/affinity-groups/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/affinity-groups/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/affinity-groups/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/affinity-groups/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "affinity-group", + Short: "Manage server affinity groups", + Long: "Manage the lifecycle of server affinity groups.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand( + create.NewCmd(p), + delete.NewCmd(p), + describe.NewCmd(p), + list.NewCmd(p), + ) +} diff --git a/internal/cmd/beta/affinity-groups/create/create.go b/internal/cmd/beta/affinity-groups/create/create.go new file mode 100644 index 000000000..41da10628 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/create/create.go @@ -0,0 +1,148 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "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/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + nameFlag = "name" + policyFlag = "policy" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string + Policy string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create affinity groups", + Long: `Create affinity groups.`, + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an affinity group with name "AFFINITY_GROUP_NAME" and policy "soft-affinity"`, + "$ stackit beta affinity-group create --name AFFINITY_GROUP_NAME --policy soft-affinity", + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create the affinity group %q?", model.Name) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, *model, apiClient) + + result, err := request.Execute() + if err != nil { + return fmt.Errorf("create affinity group: %w", err) + } + if resp := result; resp != nil { + return outputResult(p, *model, *resp) + } + return fmt.Errorf("create affinity group: nil result") + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "The name of the affinity group.") + cmd.Flags().String(policyFlag, "", `The policy for the affinity group. Valid values for the policy are: "hard-affinity", "hard-anti-affinity", "soft-affinity", "soft-anti-affinity"`) + + if err := flags.MarkFlagsRequired(cmd, nameFlag, policyFlag); err != nil { + cobra.CheckErr(err) + } +} + +func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiCreateAffinityGroupRequest { + req := apiClient.CreateAffinityGroup(ctx, model.ProjectId) + req = req.CreateAffinityGroupPayload( + iaas.CreateAffinityGroupPayload{ + Name: utils.Ptr(model.Name), + Policy: utils.Ptr(model.Policy), + }, + ) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + Policy: flags.FlagToStringValue(p, cmd, policyFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) error { + outputFormat := "" + if model.GlobalFlagModel != nil { + outputFormat = model.GlobalFlagModel.OutputFormat + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal affinity group: %w", err) + } + p.Outputln(string(details)) + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal affinity group: %w", err) + } + p.Outputln(string(details)) + default: + p.Outputf("Created affinity group %q with id %s\n", model.Name, utils.PtrString(resp.Id)) + } + return nil +} diff --git a/internal/cmd/beta/affinity-groups/create/create_test.go b/internal/cmd/beta/affinity-groups/create/create_test.go new file mode 100644 index 000000000..3ab7db59f --- /dev/null +++ b/internal/cmd/beta/affinity-groups/create/create_test.go @@ -0,0 +1,232 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testName = "test-name" + testPolicy = "test-policy" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + + nameFlag: testName, + policyFlag: testPolicy, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + Name: testName, + Policy: testPolicy, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateAffinityGroupRequest)) iaas.ApiCreateAffinityGroupRequest { + request := testClient.CreateAffinityGroup(testCtx, testProjectId) + request = request.CreateAffinityGroupPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.CreateAffinityGroupPayload)) iaas.CreateAffinityGroupPayload { + payload := iaas.CreateAffinityGroupPayload{ + Name: utils.Ptr(testName), + Policy: utils.Ptr(testPolicy), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "without name flag", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + delete(flagValues, "name") + }, + ), + isValid: false, + }, + { + description: "without policy flag", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + delete(flagValues, "policy") + }, + ), + isValid: false, + }, + { + description: "without name and policy flag", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + delete(flagValues, "policy") + delete(flagValues, "name") + }, + ), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + if err := cmd.Flags().Set(flag, value); err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model inputModel + expectedRequest iaas.ApiCreateAffinityGroupRequest + }{ + { + description: "base", + model: *fixtureInputModel(), + 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), + cmpopts.EquateComparable(testCtx)) + if diff != "" { + t.Fatalf("Request does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model inputModel + response iaas.AffinityGroup + isValid bool + }{ + { + description: "empty", + model: inputModel{}, + response: iaas.AffinityGroup{}, + isValid: true, + }, + { + description: "base", + model: *fixtureInputModel(), + response: iaas.AffinityGroup{ + Id: utils.Ptr(testProjectId), + Members: utils.Ptr([]string{uuid.NewString(), uuid.NewString()}), + Name: utils.Ptr("test-project"), + Policy: utils.Ptr("hard-affinity"), + }, + isValid: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, tt.response) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error output result: %v", err) + return + } + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + return + } + }) + } +} diff --git a/internal/cmd/beta/affinity-groups/delete/delete.go b/internal/cmd/beta/affinity-groups/delete/delete.go new file mode 100644 index 000000000..938a84cb0 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/delete/delete.go @@ -0,0 +1,113 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "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/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AffinityGroupId string +} + +const ( + affinityGroupIdArg = "AFFINITY_GROUP" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", affinityGroupIdArg), + Short: "Delete affinity group", + Long: `Delete affinity group.`, + Args: args.SingleArg(affinityGroupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an affinity group with ID "xxx"`, + "$ stackit beta affinity-group delete xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + affinityGroupLabel, err := iaasUtils.GetAffinityGroupName(ctx, apiClient, model.ProjectId, model.AffinityGroupId) + if err != nil { + p.Debug(print.ErrorLevel, "get affinity group name: %v", err) + affinityGroupLabel = model.AffinityGroupId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete affinity group %q?", affinityGroupLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, *model, apiClient) + err = request.Execute() + if err != nil { + return fmt.Errorf("delete affinity group: %w", err) + } + p.Info("Deleted affinity group %q for %q\n", affinityGroupLabel, projectLabel) + + return nil + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteAffinityGroupRequest { + return apiClient.DeleteAffinityGroup(ctx, model.ProjectId, model.AffinityGroupId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AffinityGroupId: cliArgs[0], + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} diff --git a/internal/cmd/beta/affinity-groups/delete/delete_test.go b/internal/cmd/beta/affinity-groups/delete/delete_test.go new file mode 100644 index 000000000..71f2b84a7 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/delete/delete_test.go @@ -0,0 +1,176 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + + testAffinityGroupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAffinityGroupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + AffinityGroupId: testAffinityGroupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteAffinityGroupRequest)) iaas.ApiDeleteAffinityGroupRequest { + request := testClient.DeleteAffinityGroup(testCtx, testProjectId, testAffinityGroupId) + 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: "without args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "without flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err = cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model inputModel + expectedRequest iaas.ApiDeleteAffinityGroupRequest + }{ + { + description: "base", + model: *fixtureInputModel(), + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/affinity-groups/describe/describe.go b/internal/cmd/beta/affinity-groups/describe/describe.go new file mode 100644 index 000000000..fe3824b17 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/describe/describe.go @@ -0,0 +1,141 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "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/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AffinityGroupId string +} + +const ( + affinityGroupId = "AFFINITY_GROUP_ID" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", affinityGroupId), + Short: "Describes affinity group", + Long: `Describes affinity group by it's ID.`, + Args: args.SingleArg(affinityGroupId, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details about an affinity group with the ID "xxx"`, + "$ stackit beta affinity-group describe xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, *model, apiClient) + result, err := request.Execute() + if err != nil { + return fmt.Errorf("get affinity group: %w", err) + } + + if err := outputResult(p, *model, *result); err != nil { + return err + } + return nil + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiGetAffinityGroupRequest { + return apiClient.GetAffinityGroup(ctx, model.ProjectId, model.AffinityGroupId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AffinityGroupId: cliArgs[0], + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) error { + var outputFormat string + if model.GlobalFlagModel != nil { + outputFormat = model.GlobalFlagModel.OutputFormat + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal affinity group: %w", err) + } + p.Outputln(string(details)) + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal affinity group: %w", err) + } + p.Outputln(string(details)) + default: + table := tables.NewTable() + + if resp.HasId() { + table.AddRow("ID", utils.PtrString(resp.Id)) + table.AddSeparator() + } + if resp.Name != nil { + table.AddRow("NAME", utils.PtrString(resp.Name)) + table.AddSeparator() + } + if resp.Policy != nil { + table.AddRow("POLICY", utils.PtrString(resp.Policy)) + table.AddSeparator() + } + if resp.HasMembers() { + table.AddRow("Members", utils.JoinStringPtr(resp.Members, ", ")) + table.AddSeparator() + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + } + return nil +} diff --git a/internal/cmd/beta/affinity-groups/describe/describe_test.go b/internal/cmd/beta/affinity-groups/describe/describe_test.go new file mode 100644 index 000000000..1d8a1f23b --- /dev/null +++ b/internal/cmd/beta/affinity-groups/describe/describe_test.go @@ -0,0 +1,212 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), &testCtxKey{}, projectIdFlag) + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + + testAffinityGroupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAffinityGroupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + AffinityGroupId: testAffinityGroupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetAffinityGroupRequest)) iaas.ApiGetAffinityGroupRequest { + request := testClient.GetAffinityGroup(testCtx, testProjectId, testAffinityGroupId) + 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: "without args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "without flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + {}, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err = cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model inputModel + expectedRequest iaas.ApiGetAffinityGroupRequest + }{ + { + description: "base", + model: *fixtureInputModel(), + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model inputModel + isValid bool + response iaas.AffinityGroup + }{ + { + description: "empty", + model: inputModel{}, + isValid: true, + response: iaas.AffinityGroup{}, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(p) + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, tt.response) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error output result: %v", err) + return + } + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + return + } + }) + } +} diff --git a/internal/cmd/beta/affinity-groups/list/list.go b/internal/cmd/beta/affinity-groups/list/list.go new file mode 100644 index 000000000..bec006987 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/list/list.go @@ -0,0 +1,155 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "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/services/iaas/client" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const limitFlag = "limit" + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists affinity groups", + Long: `Lists affinity groups.`, + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + "List all affinity groups", + "$ stackit beta affinity-group list", + ), + examples.NewExample( + "List the first 5 affinity groups", + "$ stackit beta affinity-group list --limit=10", + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, *model, apiClient) + result, err := request.Execute() + if err != nil { + return fmt.Errorf("list affinity groups: %w", err) + } + + if items := result.Items; items != nil { + if model.Limit != nil && len(*items) > int(*model.Limit) { + *items = (*items)[:*model.Limit] + } + return outputResult(p, *model, *items) + } + + p.Outputln("No affinity groups found") + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiListAffinityGroupsRequest { + return apiClient.ListAffinityGroups(ctx, model.ProjectId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, model inputModel, items []iaas.AffinityGroup) error { + var outputFormat string + if model.GlobalFlagModel != nil { + outputFormat = model.GlobalFlagModel.OutputFormat + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal affinity groups: %w", err) + } + p.Outputln(string(details)) + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal affinity groups: %w", err) + } + p.Outputln(string(details)) + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "POLICY") + for _, item := range items { + table.AddRow( + utils.PtrString(item.Id), + utils.PtrString(item.Name), + utils.PtrString(item.Policy), + ) + table.AddSeparator() + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + } + return nil +} diff --git a/internal/cmd/beta/affinity-groups/list/list_test.go b/internal/cmd/beta/affinity-groups/list/list_test.go new file mode 100644 index 000000000..da35b5778 --- /dev/null +++ b/internal/cmd/beta/affinity-groups/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testLimit = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListAffinityGroupsRequest)) iaas.ApiListAffinityGroupsRequest { + request := testClient.ListAffinityGroups(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "without flags", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "with limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(testLimit) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(testLimit)) + }), + }, + { + description: "with limit flag == 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(0) + }), + isValid: false, + }, + { + description: "with limit flag < 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(-1) + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + if err := cmd.Flags().Set(flag, value); err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model inputModel + expectedRequest iaas.ApiListAffinityGroupsRequest + }{ + { + description: "base", + model: *fixtureInputModel(), + 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), + cmpopts.EquateComparable(testCtx)) + if diff != "" { + t.Fatalf("Request does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model inputModel + response []iaas.AffinityGroup + isValid bool + }{ + { + description: "empty", + model: inputModel{}, + response: []iaas.AffinityGroup{}, + isValid: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, tt.response) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error output result: %v", err) + return + } + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + return + } + }) + } +} diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 72ed0c5f2..d5b30e94a 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -3,7 +3,8 @@ package beta import ( "fmt" - image "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image" + affinityGroups "github.com/stackitcloud/stackit-cli/internal/cmd/beta/affinity-groups" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image" keypair "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network" networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-area" @@ -56,4 +57,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(keypair.NewCmd(p)) cmd.AddCommand(image.NewCmd(p)) cmd.AddCommand(quota.NewCmd(p)) + cmd.AddCommand(affinityGroups.NewCmd(p)) } diff --git a/internal/pkg/services/iaas/utils/utils.go b/internal/pkg/services/iaas/utils/utils.go index c3102cfed..b3456d254 100644 --- a/internal/pkg/services/iaas/utils/utils.go +++ b/internal/pkg/services/iaas/utils/utils.go @@ -18,6 +18,7 @@ type IaaSClient interface { ListNetworkAreaProjectsExecute(ctx context.Context, organizationId, areaId string) (*iaas.ProjectListResponse, error) GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, networkRangeId string) (*iaas.NetworkRange, error) GetImageExecute(ctx context.Context, projectId string, imageId string) (*iaas.Image, error) + GetAffinityGroupExecute(ctx context.Context, projectId string, affinityGroupId string) (*iaas.AffinityGroup, error) } func GetSecurityGroupRuleName(ctx context.Context, apiClient IaaSClient, projectId, securityGroupRuleId, securityGroupId string) (string, error) { @@ -129,3 +130,14 @@ func GetImageName(ctx context.Context, apiClient IaaSClient, projectId, imageId } return *resp.Name, nil } + +func GetAffinityGroupName(ctx context.Context, apiClient IaaSClient, projectId, affinityGroupId string) (string, error) { + resp, err := apiClient.GetAffinityGroupExecute(ctx, projectId, affinityGroupId) + if err != nil { + return "", fmt.Errorf("get affinity group: %w", err) + } + if resp.Name == nil { + return "", nil + } + return *resp.Name, nil +} diff --git a/internal/pkg/services/iaas/utils/utils_test.go b/internal/pkg/services/iaas/utils/utils_test.go index 01c59aa70..d62dac35c 100644 --- a/internal/pkg/services/iaas/utils/utils_test.go +++ b/internal/pkg/services/iaas/utils/utils_test.go @@ -31,6 +31,15 @@ type IaaSClientMocked struct { GetNetworkAreaRangeResp *iaas.NetworkRange GetImageFails bool GetImageResp *iaas.Image + GetAffinityGroupsFails bool + GetAffinityGroupResp *iaas.AffinityGroup +} + +func (m *IaaSClientMocked) GetAffinityGroupExecute(_ context.Context, _, _ string) (*iaas.AffinityGroup, error) { + if m.GetAffinityGroupsFails { + return nil, fmt.Errorf("could not get affinity groups") + } + return m.GetAffinityGroupResp, nil } func (m *IaaSClientMocked) GetSecurityGroupRuleExecute(_ context.Context, _, _, _ string) (*iaas.SecurityGroupRule, error) { @@ -715,3 +724,48 @@ func TestGetImageName(t *testing.T) { }) } } + +func TestGetAffinityGroupName(t *testing.T) { + tests := []struct { + name string + affinityResp *iaas.AffinityGroup + affinityErr bool + want string + wantErr bool + }{ + { + name: "successful retrieval", + affinityResp: &iaas.AffinityGroup{Name: utils.Ptr("test-affinity")}, + want: "test-affinity", + wantErr: false, + }, + { + name: "error on retrieval", + affinityErr: true, + wantErr: true, + }, + { + name: "nil affinity group name", + affinityErr: false, + affinityResp: &iaas.AffinityGroup{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := &IaaSClientMocked{ + GetAffinityGroupsFails: tt.affinityErr, + GetAffinityGroupResp: tt.affinityResp, + } + got, err := GetAffinityGroupName(ctx, client, "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetAffinityGroupName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetAffinityGroupName() = %v, want %v", got, tt.want) + } + }) + } +} From c9da89569b27407b83bd6abbb7186cf7e5d5d083 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Mon, 10 Feb 2025 15:34:06 +0100 Subject: [PATCH 2/4] Update docs --- docs/stackit_beta_affinity-group.md | 6 +++--- docs/stackit_beta_affinity-group_create.md | 4 ++-- docs/stackit_beta_affinity-group_delete.md | 4 ++-- docs/stackit_beta_affinity-group_describe.md | 4 ++-- docs/stackit_beta_affinity-group_list.md | 4 ++-- internal/cmd/beta/affinity-groups/create/create.go | 4 ++-- internal/cmd/beta/affinity-groups/delete/delete.go | 4 ++-- internal/cmd/beta/affinity-groups/describe/describe.go | 4 ++-- internal/cmd/beta/affinity-groups/list/list.go | 4 ++-- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/stackit_beta_affinity-group.md b/docs/stackit_beta_affinity-group.md index fb00bb0e0..cf63d5263 100644 --- a/docs/stackit_beta_affinity-group.md +++ b/docs/stackit_beta_affinity-group.md @@ -30,8 +30,8 @@ stackit beta affinity-group [flags] ### SEE ALSO * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands -* [stackit beta affinity-group create](./stackit_beta_affinity-group_create.md) - Create affinity groups -* [stackit beta affinity-group delete](./stackit_beta_affinity-group_delete.md) - Delete affinity group -* [stackit beta affinity-group describe](./stackit_beta_affinity-group_describe.md) - Describes affinity group +* [stackit beta affinity-group create](./stackit_beta_affinity-group_create.md) - Creates an affinity groups +* [stackit beta affinity-group delete](./stackit_beta_affinity-group_delete.md) - Deletes an affinity group +* [stackit beta affinity-group describe](./stackit_beta_affinity-group_describe.md) - Show details of an affinity group * [stackit beta affinity-group list](./stackit_beta_affinity-group_list.md) - Lists affinity groups diff --git a/docs/stackit_beta_affinity-group_create.md b/docs/stackit_beta_affinity-group_create.md index c758d1d8f..2eab22578 100644 --- a/docs/stackit_beta_affinity-group_create.md +++ b/docs/stackit_beta_affinity-group_create.md @@ -1,10 +1,10 @@ ## stackit beta affinity-group create -Create affinity groups +Creates an affinity groups ### Synopsis -Create affinity groups. +Creates an affinity groups. ``` stackit beta affinity-group create [flags] diff --git a/docs/stackit_beta_affinity-group_delete.md b/docs/stackit_beta_affinity-group_delete.md index 3d41e5126..e4e43be34 100644 --- a/docs/stackit_beta_affinity-group_delete.md +++ b/docs/stackit_beta_affinity-group_delete.md @@ -1,10 +1,10 @@ ## stackit beta affinity-group delete -Delete affinity group +Deletes an affinity group ### Synopsis -Delete affinity group. +Deletes an affinity group. ``` stackit beta affinity-group delete AFFINITY_GROUP [flags] diff --git a/docs/stackit_beta_affinity-group_describe.md b/docs/stackit_beta_affinity-group_describe.md index accb956e6..272c9b291 100644 --- a/docs/stackit_beta_affinity-group_describe.md +++ b/docs/stackit_beta_affinity-group_describe.md @@ -1,10 +1,10 @@ ## stackit beta affinity-group describe -Describes affinity group +Show details of an affinity group ### Synopsis -Describes affinity group by it's ID. +Show details of an affinity group. ``` stackit beta affinity-group describe AFFINITY_GROUP_ID [flags] diff --git a/docs/stackit_beta_affinity-group_list.md b/docs/stackit_beta_affinity-group_list.md index 059d0aeaf..ac79cb40a 100644 --- a/docs/stackit_beta_affinity-group_list.md +++ b/docs/stackit_beta_affinity-group_list.md @@ -13,10 +13,10 @@ stackit beta affinity-group list [flags] ### Examples ``` - List all affinity groups + Lists all affinity groups $ stackit beta affinity-group list - List the first 5 affinity groups + Lists up to 10 affinity groups $ stackit beta affinity-group list --limit=10 ``` diff --git a/internal/cmd/beta/affinity-groups/create/create.go b/internal/cmd/beta/affinity-groups/create/create.go index 41da10628..dfc4b91a0 100644 --- a/internal/cmd/beta/affinity-groups/create/create.go +++ b/internal/cmd/beta/affinity-groups/create/create.go @@ -32,8 +32,8 @@ type inputModel struct { func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Create affinity groups", - Long: `Create affinity groups.`, + Short: "Creates an affinity groups", + Long: `Creates an affinity groups.`, Args: args.NoArgs, Example: examples.Build( examples.NewExample( diff --git a/internal/cmd/beta/affinity-groups/delete/delete.go b/internal/cmd/beta/affinity-groups/delete/delete.go index 938a84cb0..f48dbd6a6 100644 --- a/internal/cmd/beta/affinity-groups/delete/delete.go +++ b/internal/cmd/beta/affinity-groups/delete/delete.go @@ -29,8 +29,8 @@ const ( func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", affinityGroupIdArg), - Short: "Delete affinity group", - Long: `Delete affinity group.`, + Short: "Deletes an affinity group", + Long: `Deletes an affinity group.`, Args: args.SingleArg(affinityGroupIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( diff --git a/internal/cmd/beta/affinity-groups/describe/describe.go b/internal/cmd/beta/affinity-groups/describe/describe.go index fe3824b17..d31160a3a 100644 --- a/internal/cmd/beta/affinity-groups/describe/describe.go +++ b/internal/cmd/beta/affinity-groups/describe/describe.go @@ -30,8 +30,8 @@ const ( func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", affinityGroupId), - Short: "Describes affinity group", - Long: `Describes affinity group by it's ID.`, + Short: "Show details of an affinity group", + Long: `Show details of an affinity group.`, Args: args.SingleArg(affinityGroupId, utils.ValidateUUID), Example: examples.Build( examples.NewExample( diff --git a/internal/cmd/beta/affinity-groups/list/list.go b/internal/cmd/beta/affinity-groups/list/list.go index bec006987..cb7d40bf6 100644 --- a/internal/cmd/beta/affinity-groups/list/list.go +++ b/internal/cmd/beta/affinity-groups/list/list.go @@ -35,11 +35,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - "List all affinity groups", + "Lists all affinity groups", "$ stackit beta affinity-group list", ), examples.NewExample( - "List the first 5 affinity groups", + "Lists up to 10 affinity groups", "$ stackit beta affinity-group list --limit=10", ), ), From 53aa6717b92540bdb8767f29c40544cdef3008ed Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Mon, 10 Feb 2025 17:53:14 +0100 Subject: [PATCH 3/4] Add `yaml.UseJSONMarshaler()` to `yaml.MarshalWithOptions()`` calls --- internal/cmd/beta/affinity-groups/create/create.go | 2 +- internal/cmd/beta/affinity-groups/describe/describe.go | 2 +- internal/cmd/beta/affinity-groups/list/list.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/beta/affinity-groups/create/create.go b/internal/cmd/beta/affinity-groups/create/create.go index dfc4b91a0..fb3a3dae7 100644 --- a/internal/cmd/beta/affinity-groups/create/create.go +++ b/internal/cmd/beta/affinity-groups/create/create.go @@ -136,7 +136,7 @@ func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) e } p.Outputln(string(details)) case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) if err != nil { return fmt.Errorf("marshal affinity group: %w", err) } diff --git a/internal/cmd/beta/affinity-groups/describe/describe.go b/internal/cmd/beta/affinity-groups/describe/describe.go index d31160a3a..197cf1b37 100644 --- a/internal/cmd/beta/affinity-groups/describe/describe.go +++ b/internal/cmd/beta/affinity-groups/describe/describe.go @@ -108,7 +108,7 @@ func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) e } p.Outputln(string(details)) case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) if err != nil { return fmt.Errorf("marshal affinity group: %w", err) } diff --git a/internal/cmd/beta/affinity-groups/list/list.go b/internal/cmd/beta/affinity-groups/list/list.go index cb7d40bf6..260eb787b 100644 --- a/internal/cmd/beta/affinity-groups/list/list.go +++ b/internal/cmd/beta/affinity-groups/list/list.go @@ -130,7 +130,7 @@ func outputResult(p *print.Printer, model inputModel, items []iaas.AffinityGroup } p.Outputln(string(details)) case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true)) + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) if err != nil { return fmt.Errorf("marshal affinity groups: %w", err) } From dff1549bb8f05de8d2bae2d5c56491540e5d5bf0 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Tue, 11 Feb 2025 09:25:46 +0100 Subject: [PATCH 4/4] Revert unrelated docs changed --- docs/stackit_beta_image_update.md | 41 ++++++++++++++++--------------- docs/stackit_beta_quota_list.md | 2 +- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/stackit_beta_image_update.md b/docs/stackit_beta_image_update.md index c8355b0bb..760d561de 100644 --- a/docs/stackit_beta_image_update.md +++ b/docs/stackit_beta_image_update.md @@ -23,26 +23,27 @@ stackit beta image update IMAGE_ID [flags] ### Options ``` - --boot-menu Enables the BIOS bootmenu. - --cdrom-bus string Sets CDROM bus controller type. - --disk-bus string Sets Disk bus controller type. - --disk-format string The disk format of the image. - -h, --help Help for "stackit beta image update" - --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) - --min-disk-size int Size in Gigabyte. - --min-ram int Size in Megabyte. - --name string The name of the image. - --nic-model string Sets virtual nic model. - --os string Enables OS specific optimizations. - --os-distro string Operating System Distribution. - --os-version string Version of the OS. - --protected Protected VM. - --rescue-bus string Sets the device bus when the image is used as a rescue image. - --rescue-device string Sets the device when the image is used as a rescue image. - --secure-boot Enables Secure Boot. - --uefi Enables UEFI boot. - --video-model string Sets Graphic device model. - --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. + --boot-menu Enables the BIOS bootmenu. + --cdrom-bus string Sets CDROM bus controller type. + --disk-bus string Sets Disk bus controller type. + --disk-format string The disk format of the image. + -h, --help Help for "stackit beta image update" + --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) + --local-file-path string The path to the local disk image file. + --min-disk-size int Size in Gigabyte. + --min-ram int Size in Megabyte. + --name string The name of the image. + --nic-model string Sets virtual nic model. + --os string Enables OS specific optimizations. + --os-distro string Operating System Distribution. + --os-version string Version of the OS. + --protected Protected VM. + --rescue-bus string Sets the device bus when the image is used as a rescue image. + --rescue-device string Sets the device when the image is used as a rescue image. + --secure-boot Enables Secure Boot. + --uefi Enables UEFI boot. + --video-model string Sets Graphic device model. + --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. ``` ### Options inherited from parent commands diff --git a/docs/stackit_beta_quota_list.md b/docs/stackit_beta_quota_list.md index 13203ab3d..f9a6fa62c 100644 --- a/docs/stackit_beta_quota_list.md +++ b/docs/stackit_beta_quota_list.md @@ -4,7 +4,7 @@ Lists quotas ### Synopsis -Lists project quotas. +Lists server quotas. ``` stackit beta quota list [flags]