From 96df1f2c2b7a41c24da3ee5bb5557b78f5d108a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 19 Nov 2024 12:12:56 +0100 Subject: [PATCH 01/21] onboard iaas server commands --- docs/stackit_beta_server.md | 5 + docs/stackit_beta_server_create.md | 63 ++++ docs/stackit_beta_server_delete.md | 41 ++ docs/stackit_beta_server_describe.md | 46 +++ docs/stackit_beta_server_list.md | 54 +++ docs/stackit_beta_server_update.md | 44 +++ internal/cmd/beta/server/create/create.go | 282 ++++++++++++++ .../cmd/beta/server/create/create_test.go | 351 ++++++++++++++++++ internal/cmd/beta/server/delete/delete.go | 129 +++++++ .../cmd/beta/server/delete/delete_test.go | 218 +++++++++++ internal/cmd/beta/server/describe/describe.go | 214 +++++++++++ .../cmd/beta/server/describe/describe_test.go | 232 ++++++++++++ internal/cmd/beta/server/list/list.go | 189 ++++++++++ internal/cmd/beta/server/list/list_test.go | 217 +++++++++++ internal/cmd/beta/server/server.go | 10 + internal/cmd/beta/server/update/update.go | 168 +++++++++ .../cmd/beta/server/update/update_test.go | 252 +++++++++++++ internal/pkg/services/iaas/utils/utils.go | 9 + .../pkg/services/iaas/utils/utils_test.go | 55 +++ 19 files changed, 2579 insertions(+) create mode 100644 docs/stackit_beta_server_create.md create mode 100644 docs/stackit_beta_server_delete.md create mode 100644 docs/stackit_beta_server_describe.md create mode 100644 docs/stackit_beta_server_list.md create mode 100644 docs/stackit_beta_server_update.md create mode 100644 internal/cmd/beta/server/create/create.go create mode 100644 internal/cmd/beta/server/create/create_test.go create mode 100644 internal/cmd/beta/server/delete/delete.go create mode 100644 internal/cmd/beta/server/delete/delete_test.go create mode 100644 internal/cmd/beta/server/describe/describe.go create mode 100644 internal/cmd/beta/server/describe/describe_test.go create mode 100644 internal/cmd/beta/server/list/list.go create mode 100644 internal/cmd/beta/server/list/list_test.go create mode 100644 internal/cmd/beta/server/update/update.go create mode 100644 internal/cmd/beta/server/update/update_test.go diff --git a/docs/stackit_beta_server.md b/docs/stackit_beta_server.md index 1a2b67b25..2c96d2c32 100644 --- a/docs/stackit_beta_server.md +++ b/docs/stackit_beta_server.md @@ -31,4 +31,9 @@ stackit beta server [flags] * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands * [stackit beta server backup](./stackit_beta_server_backup.md) - Provides functionality for Server Backup * [stackit beta server command](./stackit_beta_server_command.md) - Provides functionality for Server Command +* [stackit beta server create](./stackit_beta_server_create.md) - Creates a server +* [stackit beta server delete](./stackit_beta_server_delete.md) - Deletes a server +* [stackit beta server describe](./stackit_beta_server_describe.md) - Shows details of a server +* [stackit beta server list](./stackit_beta_server_list.md) - Lists all servers of a project +* [stackit beta server update](./stackit_beta_server_update.md) - Updates a server diff --git a/docs/stackit_beta_server_create.md b/docs/stackit_beta_server_create.md new file mode 100644 index 000000000..b6d95b7ce --- /dev/null +++ b/docs/stackit_beta_server_create.md @@ -0,0 +1,63 @@ +## stackit beta server create + +Creates a server + +### Synopsis + +Creates a server. + +``` +stackit beta server create [flags] +``` + +### Examples + +``` + Create a server with machine type "t1.1", name "server1" and image with id xxx + $ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx + + Create a server with machine type "t1.1", name "server1", image with id xxx and labels + $ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --labels key=value,foo=bar + + Create a server with machine type "t1.1", name "server1", boot volume source id "xxx", type "image" and size 64GB + $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 +``` + +### Options + +``` + --affinity-group string The affinity group the server is assigned to + --availability-zone string Availability zone + --boot-volume-delete-on-termination Delete the volume during the termination of the server. Defaults to false + --boot-volume-performance-class string Boot volume performance class + --boot-volume-size int Boot volume size (GB). Size is required for the image type boot volumes + --boot-volume-source-id string ID of the source object of boot volume. It can be either 'image-id' or 'volume-id' + --boot-volume-source-type string Type of the source object of boot volume. It can be either 'image' or 'volume' + -h, --help Help for "stackit beta server create" + --image-id string ID of the image. Either image-id or boot volume is required + --keypair-name string The SSH keypair used during the server creation + --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default []) + --machine-type string Machine type the server shall belong to + -n, --name string Server name + --network-id string ID of the network for the initial networking setup for the server creation + --network-interface-ids strings List of network interface IDs for the initial networking setup for the server creation + --security-groups strings The initial security groups for the server creation + --service-account-emails strings List of the service account mails + --user-data string User data that is provided to the server + --volumes strings The list of volumes attached to the server +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server](./stackit_beta_server.md) - Provides functionality for Server + diff --git a/docs/stackit_beta_server_delete.md b/docs/stackit_beta_server_delete.md new file mode 100644 index 000000000..ffe1f941c --- /dev/null +++ b/docs/stackit_beta_server_delete.md @@ -0,0 +1,41 @@ +## stackit beta server delete + +Deletes a server + +### Synopsis + +Deletes a server. +If the server is still in use, the deletion will fail + + +``` +stackit beta server delete [flags] +``` + +### Examples + +``` + Delete server with ID "xxx" + $ stackit beta server delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta server 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server](./stackit_beta_server.md) - Provides functionality for Server + diff --git a/docs/stackit_beta_server_describe.md b/docs/stackit_beta_server_describe.md new file mode 100644 index 000000000..1dab42995 --- /dev/null +++ b/docs/stackit_beta_server_describe.md @@ -0,0 +1,46 @@ +## stackit beta server describe + +Shows details of a server + +### Synopsis + +Shows details of a server. + +``` +stackit beta server describe [flags] +``` + +### Examples + +``` + Show details of a server with ID "xxx" + $ stackit beta server describe xxx + + Show detailed information of a server with ID "xxx" + $ stackit beta server describe xxx --details + + Show details of a server with ID "xxx" in JSON format + $ stackit beta server describe xxx --output-format json +``` + +### Options + +``` + --details Show detailed information about server + -h, --help Help for "stackit beta server 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server](./stackit_beta_server.md) - Provides functionality for Server + diff --git a/docs/stackit_beta_server_list.md b/docs/stackit_beta_server_list.md new file mode 100644 index 000000000..3a4329380 --- /dev/null +++ b/docs/stackit_beta_server_list.md @@ -0,0 +1,54 @@ +## stackit beta server list + +Lists all servers of a project + +### Synopsis + +Lists all servers of a project. + +``` +stackit beta server list [flags] +``` + +### Examples + +``` + Lists all servers + $ stackit beta server list + + Lists all servers which contains the label xxx + $ stackit beta server list --label-selector xxx + + Lists all servers with detailed information + $ stackit beta server list --details + + Lists all servers in JSON format + $ stackit beta server list --output-format json + + Lists up to 10 servers + $ stackit beta server list --limit 10 +``` + +### Options + +``` + --details Show detailed information about server + -h, --help Help for "stackit beta server list" + --label-selector string Filter by label + --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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server](./stackit_beta_server.md) - Provides functionality for Server + diff --git a/docs/stackit_beta_server_update.md b/docs/stackit_beta_server_update.md new file mode 100644 index 000000000..633b75fbd --- /dev/null +++ b/docs/stackit_beta_server_update.md @@ -0,0 +1,44 @@ +## stackit beta server update + +Updates a server + +### Synopsis + +Updates a server. + +``` +stackit beta server update [flags] +``` + +### Examples + +``` + Update server with ID "xxx" with new name "server-1-new" + $ stackit beta server update xxx --name server-1-new + + Update server with ID "xxx" with new name "server-1-new" and label(s) + $ stackit beta server update xxx --name server-1-new --labels key=value,foo=bar +``` + +### Options + +``` + -h, --help Help for "stackit beta server update" + --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default []) + -n, --name string Server name +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server](./stackit_beta_server.md) - Provides functionality for Server + diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go new file mode 100644 index 000000000..44fa6b16a --- /dev/null +++ b/internal/cmd/beta/server/create/create.go @@ -0,0 +1,282 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "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/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + "github.com/spf13/cobra" +) + +const ( + nameFlag = "name" + machineTypeFlag = "machine-type" + affinityGroupFlag = "affinity-group" + availabilityZoneFlag = "availability-zone" + bootVolumeSourceIdFlag = "boot-volume-source-id" + bootVolumeSourceTypeFlag = "boot-volume-source-type" + bootVolumeSizeFlag = "boot-volume-size" + bootVolumePerformanceClassFlag = "boot-volume-performance-class" + bootVolumeDeleteOnTerminationFlag = "boot-volume-delete-on-termination" + imageIdFlag = "image-id" + keypairNameFlag = "keypair-name" + labelFlag = "labels" + networkIdFlag = "network-id" + networkInterfaceIdsFlag = "network-interface-ids" + securityGroupsFlag = "security-groups" + serviceAccountEmailsFlag = "service-account-emails" + userDataFlag = "user-data" + volumesFlag = "volumes" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name *string + MachineType *string + AffinityGroup *string + AvailabilityZone *string + BootVolumeSourceId *string + BootVolumeSourceType *string + BootVolumeSize *int64 + BootVolumePerformanceClass *string + BootVolumeDeleteOnTermination *bool + ImageId *string + KeypairName *string + Labels *map[string]string + NetworkId *string + NetworkInterfaceIds *[]string + SecurityGroups *[]string + ServiceAccountMails *[]string + UserData *string + Volumes *[]string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a server", + Long: "Creates a server.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a server with machine type "t1.1", name "server1" and image with id xxx`, + `$ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx`, + ), + examples.NewExample( + `Create a server with machine type "t1.1", name "server1", image with id xxx and labels`, + `$ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --labels key=value,foo=bar`, + ), + examples.NewExample( + `Create a server with machine type "t1.1", name "server1", boot volume source id "xxx", type "image" and size 64GB`, + `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64`, + ), + ), + RunE: func(cmd *cobra.Command, args []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 + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a server for project %q?", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create server : %w", err) + } + serverId := *resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Creating server") + _, err = wait.CreateServerWaitHandler(ctx, apiClient, model.ProjectId, serverId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for server creation: %w", err) + } + s.Stop() + } + + return outputResult(p, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "Server name") + cmd.Flags().String(machineTypeFlag, "", "Machine type the server shall belong to") + cmd.Flags().String(affinityGroupFlag, "", "The affinity group the server is assigned to") + cmd.Flags().String(availabilityZoneFlag, "", "Availability zone") + cmd.Flags().String(bootVolumeSourceIdFlag, "", "ID of the source object of boot volume. It can be either 'image-id' or 'volume-id'") + cmd.Flags().String(bootVolumeSourceTypeFlag, "", "Type of the source object of boot volume. It can be either 'image' or 'volume'") + cmd.Flags().Int64(bootVolumeSizeFlag, 0, "Boot volume size (GB). Size is required for the image type boot volumes") + cmd.Flags().String(bootVolumePerformanceClassFlag, "", "Boot volume performance class") + cmd.Flags().Bool(bootVolumeDeleteOnTerminationFlag, false, "Delete the volume during the termination of the server. Defaults to false") + cmd.Flags().String(imageIdFlag, "", "ID of the image. Either image-id or boot volume is required") + cmd.Flags().String(keypairNameFlag, "", "The SSH keypair used during the server creation") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'") + cmd.Flags().String(networkIdFlag, "", "ID of the network for the initial networking setup for the server creation") + cmd.Flags().StringSlice(networkInterfaceIdsFlag, []string{}, "List of network interface IDs for the initial networking setup for the server creation") + cmd.Flags().StringSlice(securityGroupsFlag, []string{}, "The initial security groups for the server creation") + cmd.Flags().StringSlice(serviceAccountEmailsFlag, []string{}, "List of the service account mails") + cmd.Flags().String(userDataFlag, "", "User data that is provided to the server") + cmd.Flags().StringSlice(volumesFlag, []string{}, "The list of volumes attached to the server") + + err := flags.MarkFlagsRequired(cmd, nameFlag, machineTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + MachineType: flags.FlagToStringPointer(p, cmd, machineTypeFlag), + AffinityGroup: flags.FlagToStringPointer(p, cmd, affinityGroupFlag), + AvailabilityZone: flags.FlagToStringPointer(p, cmd, availabilityZoneFlag), + BootVolumeSourceId: flags.FlagToStringPointer(p, cmd, bootVolumeSourceIdFlag), + BootVolumeSourceType: flags.FlagToStringPointer(p, cmd, bootVolumeSourceTypeFlag), + BootVolumeSize: flags.FlagToInt64Pointer(p, cmd, bootVolumeSizeFlag), + BootVolumePerformanceClass: flags.FlagToStringPointer(p, cmd, bootVolumePerformanceClassFlag), + BootVolumeDeleteOnTermination: flags.FlagToBoolPointer(p, cmd, bootVolumeDeleteOnTerminationFlag), + ImageId: flags.FlagToStringPointer(p, cmd, imageIdFlag), + KeypairName: flags.FlagToStringPointer(p, cmd, keypairNameFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NetworkInterfaceIds: flags.FlagToStringSlicePointer(p, cmd, networkInterfaceIdsFlag), + SecurityGroups: flags.FlagToStringSlicePointer(p, cmd, securityGroupsFlag), + ServiceAccountMails: flags.FlagToStringSlicePointer(p, cmd, serviceAccountEmailsFlag), + UserData: flags.FlagToStringPointer(p, cmd, userDataFlag), + Volumes: flags.FlagToStringSlicePointer(p, cmd, volumesFlag), + } + + 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 buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateServerRequest { + req := apiClient.CreateServer(ctx, model.ProjectId) + var labelsMap *map[string]interface{} + if model.Labels != nil && len(*model.Labels) > 0 { + // convert map[string]string to map[string]interface{} + labelsMap = utils.Ptr(map[string]interface{}{}) + for k, v := range *model.Labels { + (*labelsMap)[k] = v + } + } + + payload := iaas.CreateServerPayload{ + Name: model.Name, + MachineType: model.MachineType, + AffinityGroup: model.AffinityGroup, + AvailabilityZone: model.AvailabilityZone, + + ImageId: model.ImageId, + KeypairName: model.KeypairName, + SecurityGroups: model.SecurityGroups, + ServiceAccountMails: model.ServiceAccountMails, + UserData: model.UserData, + Volumes: model.Volumes, + Labels: labelsMap, + } + + if model.BootVolumePerformanceClass != nil || model.BootVolumeSize != nil || model.BootVolumeDeleteOnTermination != nil || model.BootVolumeSourceId != nil || model.BootVolumeSourceType != nil { + payload.BootVolume = &iaas.CreateServerPayloadBootVolume{ + PerformanceClass: model.BootVolumePerformanceClass, + Size: model.BootVolumeSize, + DeleteOnTermination: model.BootVolumeDeleteOnTermination, + Source: &iaas.BootVolumeSource{ + Id: model.BootVolumeSourceId, + Type: model.BootVolumeSourceType, + }, + } + } + + if model.NetworkInterfaceIds != nil || model.NetworkId != nil { + payload.Networking = &iaas.CreateServerPayloadNetworking{} + + if model.NetworkInterfaceIds != nil { + payload.Networking.CreateServerNetworkingWithNics = &iaas.CreateServerNetworkingWithNics{ + NicIds: model.NetworkInterfaceIds, + } + } else if model.NetworkId != nil { + payload.Networking.CreateServerNetworking = &iaas.CreateServerNetworking{ + NetworkId: model.NetworkId, + } + } + } + + return req.CreateServerPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, server *iaas.Server) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(server, "", " ") + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(server, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Created server for project %q.\nServer ID: %s\n", projectLabel, *server.Id) + return nil + } +} diff --git a/internal/cmd/beta/server/create/create_test.go b/internal/cmd/beta/server/create/create_test.go new file mode 100644 index 000000000..99df18e0d --- /dev/null +++ b/internal/cmd/beta/server/create/create_test.go @@ -0,0 +1,351 @@ +package create + +import ( + "context" + "testing" + + "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/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var testProjectId = uuid.NewString() +var testSourceId = uuid.NewString() +var testImageId = uuid.NewString() +var testNetworkId = uuid.NewString() +var testVolumeId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + availabilityZoneFlag: "eu01-1", + nameFlag: "test-server-name", + machineTypeFlag: "t1.1", + affinityGroupFlag: "test-affinity-group", + labelFlag: "key=value", + bootVolumePerformanceClassFlag: "test-perf-class", + bootVolumeSizeFlag: "5", + bootVolumeSourceIdFlag: testSourceId, + bootVolumeSourceTypeFlag: "test-source-type", + bootVolumeDeleteOnTerminationFlag: "false", + imageIdFlag: testImageId, + keypairNameFlag: "test-keypair-name", + networkIdFlag: testNetworkId, + securityGroupsFlag: "test-security-groups", + serviceAccountEmailsFlag: "test-service-account", + userDataFlag: "test-user-data", + volumesFlag: testVolumeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + AvailabilityZone: utils.Ptr("eu01-1"), + Name: utils.Ptr("test-server-name"), + MachineType: utils.Ptr("t1.1"), + AffinityGroup: utils.Ptr("test-affinity-group"), + BootVolumePerformanceClass: utils.Ptr("test-perf-class"), + BootVolumeSize: utils.Ptr(int64(5)), + BootVolumeSourceId: utils.Ptr(testSourceId), + BootVolumeSourceType: utils.Ptr("test-source-type"), + BootVolumeDeleteOnTermination: utils.Ptr(false), + ImageId: utils.Ptr(testImageId), + KeypairName: utils.Ptr("test-keypair-name"), + NetworkId: utils.Ptr(testNetworkId), + SecurityGroups: utils.Ptr([]string{"test-security-groups"}), + ServiceAccountMails: utils.Ptr([]string{"test-service-account"}), + UserData: utils.Ptr("test-user-data"), + Volumes: utils.Ptr([]string{testVolumeId}), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.ApiCreateServerRequest { + request := testClient.CreateServer(testCtx, testProjectId) + request = request.CreateServerPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.ApiCreateServerRequest { + request := testClient.CreateServer(testCtx, testProjectId) + request = request.CreateServerPayload(iaas.CreateServerPayload{ + MachineType: utils.Ptr("t1.1"), + Name: utils.Ptr("test-server-name"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.CreateServerPayload)) iaas.CreateServerPayload { + payload := iaas.CreateServerPayload{ + Labels: utils.Ptr(map[string]interface{}{ + "key": "value", + }), + MachineType: utils.Ptr("t1.1"), + Name: utils.Ptr("test-server-name"), + AvailabilityZone: utils.Ptr("eu01-1"), + AffinityGroup: utils.Ptr("test-affinity-group"), + ImageId: utils.Ptr(testImageId), + KeypairName: utils.Ptr("test-keypair-name"), + SecurityGroups: utils.Ptr([]string{"test-security-groups"}), + ServiceAccountMails: utils.Ptr([]string{"test-service-account"}), + UserData: utils.Ptr("test-user-data"), + Volumes: utils.Ptr([]string{testVolumeId}), + BootVolume: &iaas.CreateServerPayloadBootVolume{ + PerformanceClass: utils.Ptr("test-perf-class"), + Size: utils.Ptr(int64(5)), + DeleteOnTermination: utils.Ptr(false), + Source: &iaas.BootVolumeSource{ + Id: utils.Ptr(testSourceId), + Type: utils.Ptr("test-source-type"), + }, + }, + Networking: &iaas.CreateServerPayloadNetworking{ + CreateServerNetworking: &iaas.CreateServerNetworking{ + NetworkId: utils.Ptr(testNetworkId), + }, + }, + } + 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: "required only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, affinityGroupFlag) + delete(flagValues, availabilityZoneFlag) + delete(flagValues, labelFlag) + delete(flagValues, bootVolumeSourceIdFlag) + delete(flagValues, bootVolumeSourceTypeFlag) + delete(flagValues, bootVolumeSizeFlag) + delete(flagValues, bootVolumePerformanceClassFlag) + delete(flagValues, bootVolumeDeleteOnTerminationFlag) + delete(flagValues, imageIdFlag) + delete(flagValues, keypairNameFlag) + delete(flagValues, networkIdFlag) + delete(flagValues, networkInterfaceIdsFlag) + delete(flagValues, securityGroupsFlag) + delete(flagValues, serviceAccountEmailsFlag) + delete(flagValues, userDataFlag) + delete(flagValues, volumesFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.AffinityGroup = nil + model.AvailabilityZone = nil + model.Labels = nil + model.BootVolumeSourceId = nil + model.BootVolumeSourceType = nil + model.BootVolumeSize = nil + model.BootVolumePerformanceClass = nil + model.BootVolumeDeleteOnTermination = nil + model.ImageId = nil + model.KeypairName = nil + model.NetworkId = nil + model.NetworkInterfaceIds = nil + model.SecurityGroups = nil + model.ServiceAccountMails = nil + model.UserData = nil + model.Volumes = nil + }), + }, + { + description: "machine type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, machineTypeFlag) + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "use network id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkIdFlag] = testNetworkId + flagValues[nameFlag] = "test-server-name" + flagValues[machineTypeFlag] = "t1.1" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NetworkId = utils.Ptr(testNetworkId) + model.Name = utils.Ptr("test-server-name") + model.MachineType = utils.Ptr("t1.1") + }), + }, + { + description: "use boot volume source id and type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[bootVolumeSourceIdFlag] = testImageId + flagValues[bootVolumeSourceTypeFlag] = "image" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.BootVolumeSourceId = utils.Ptr(testImageId) + model.BootVolumeSourceType = utils.Ptr("image") + }), + }, + } + + 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.ValidateRequiredFlags() + if 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.ApiCreateServerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "only name and machine type in payload", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + MachineType: utils.Ptr("t1.1"), + Name: utils.Ptr("test-server-name"), + }, + expectedRequest: fixtureRequiredRequest(), + }, + } + + 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/server/delete/delete.go b/internal/cmd/beta/server/delete/delete.go new file mode 100644 index 000000000..c82059193 --- /dev/null +++ b/internal/cmd/beta/server/delete/delete.go @@ -0,0 +1,129 @@ +package delete + +import ( + "context" + "fmt" + + "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" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + "github.com/spf13/cobra" +) + +const ( + serverIdArg = "SERVER_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes a server", + Long: fmt.Sprintf("%s\n%s\n", + "Deletes a server.", + "If the server is still in use, the deletion will fail", + ), + Args: args.SingleArg(serverIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete server with ID "xxx"`, + "$ stackit beta server 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 + } + + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + if err != nil { + p.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete server %q?", serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete server: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Deleting server") + _, err = wait.DeleteServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for server deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Info("%s server %q\n", operationState, serverLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + serverId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: serverId, + } + + 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 buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteServerRequest { + return apiClient.DeleteServer(ctx, model.ProjectId, model.ServerId) +} diff --git a/internal/cmd/beta/server/delete/delete_test.go b/internal/cmd/beta/server/delete/delete_test.go new file mode 100644 index 000000000..3b7c0ba31 --- /dev/null +++ b/internal/cmd/beta/server/delete/delete_test.go @@ -0,0 +1,218 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testServerId = uuid.NewString() +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testServerId, + } + 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, + }, + ServerId: testServerId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteServerRequest)) iaas.ApiDeleteServerRequest { + request := testClient.DeleteServer(testCtx, testProjectId, testServerId) + 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, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + 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.ApiDeleteServerRequest + }{ + { + 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/server/describe/describe.go b/internal/cmd/beta/server/describe/describe.go new file mode 100644 index 000000000..05c44ec5a --- /dev/null +++ b/internal/cmd/beta/server/describe/describe.go @@ -0,0 +1,214 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "strings" + + "github.com/goccy/go-yaml" + + "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" + + "github.com/spf13/cobra" +) + +const ( + serverIdArg = "SERVER_ID" + detailsFlag = "details" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId string + Details bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Shows details of a server", + Long: "Shows details of a server.", + Args: args.SingleArg(serverIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show details of a server with ID "xxx"`, + "$ stackit beta server describe xxx", + ), + examples.NewExample( + `Show detailed information of a server with ID "xxx"`, + "$ stackit beta server describe xxx --details", + ), + examples.NewExample( + `Show details of a server with ID "xxx" in JSON format`, + "$ stackit beta server describe xxx --output-format json", + ), + ), + 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 + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read server: %w", err) + } + + return outputResult(p, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(detailsFlag, false, "Show detailed information about server") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + serverId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: serverId, + Details: flags.FlagToBoolValue(p, cmd, detailsFlag), + } + + 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 buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetServerRequest { + req := apiClient.GetServer(ctx, model.ProjectId, model.ServerId) + + if model.Details { + req = req.Details(true) + } + + return req +} + +func outputResult(p *print.Printer, outputFormat string, server *iaas.Server) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(server, "", " ") + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(server, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("ID", *server.Id) + table.AddSeparator() + table.AddRow("NAME", *server.Name) + table.AddSeparator() + table.AddRow("STATE", *server.Status) + table.AddSeparator() + table.AddRow("AVAILABILITY ZONE", *server.AvailabilityZone) + table.AddSeparator() + table.AddRow("BOOT VOLUME", *server.BootVolume.Id) + table.AddSeparator() + table.AddRow("POWER STATUS", *server.PowerStatus) + table.AddSeparator() + + if server.AffinityGroup != nil { + table.AddRow("AFFINITY GROUP", *server.AffinityGroup) + table.AddSeparator() + } + + if server.ImageId != nil { + table.AddRow("IMAGE", *server.ImageId) + table.AddSeparator() + } + + if server.KeypairName != nil { + table.AddRow("KEYPAIR", *server.KeypairName) + table.AddSeparator() + } + + if server.MachineType != nil { + table.AddRow("MACHINE TYPE", *server.MachineType) + table.AddSeparator() + } + + if server.Nics != nil && len(*server.Nics) > 0 { + nics := []string{} + for _, nic := range *server.Nics { + nics = append(nics, *nic.NicId) + } + table.AddRow("NICS", strings.Join(nics, "\n")) + } + + if server.Labels != nil && len(*server.Labels) > 0 { + labels := []string{} + for key, value := range *server.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + } + + if server.ServiceAccountMails != nil && len(*server.ServiceAccountMails) > 0 { + emails := []string{} + for _, email := range *server.ServiceAccountMails { + emails = append(emails, email) + } + table.AddRow("SERVICE ACCOUNTS", strings.Join(emails, "\n")) + } + + if server.Volumes != nil && len(*server.Volumes) > 0 { + volumes := []string{} + for _, volume := range *server.Volumes { + volumes = append(volumes, volume) + } + table.AddRow("VOLUMES", strings.Join(volumes, "\n")) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + } +} diff --git a/internal/cmd/beta/server/describe/describe_test.go b/internal/cmd/beta/server/describe/describe_test.go new file mode 100644 index 000000000..0c8c2ff13 --- /dev/null +++ b/internal/cmd/beta/server/describe/describe_test.go @@ -0,0 +1,232 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testServerId, + } + 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, + detailsFlag: "true", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ServerId: testServerId, + Details: true, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetServerRequest)) iaas.ApiGetServerRequest { + request := testClient.GetServer(testCtx, testProjectId, testServerId) + request = request.Details(true) + 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, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "details flag false", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[detailsFlag] = "false" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Details = 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.ApiGetServerRequest + }{ + { + 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/server/list/list.go b/internal/cmd/beta/server/list/list.go new file mode 100644 index 000000000..b8cb7b879 --- /dev/null +++ b/internal/cmd/beta/server/list/list.go @@ -0,0 +1,189 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/spf13/cobra" +) + +const ( + limitFlag = "limit" + labelSelectorFlag = "label-selector" + detailsFlag = "details" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + LabelSelector *string + Details bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all servers of a project", + Long: "Lists all servers of a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all servers`, + "$ stackit beta server list", + ), + examples.NewExample( + `Lists all servers which contains the label xxx`, + "$ stackit beta server list --label-selector xxx", + ), + examples.NewExample( + `Lists all servers with detailed information`, + "$ stackit beta server list --details", + ), + examples.NewExample( + `Lists all servers in JSON format`, + "$ stackit beta server list --output-format json", + ), + examples.NewExample( + `Lists up to 10 servers`, + "$ stackit beta server list --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []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 + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list servers: %w", err) + } + + if resp.Items == nil || len(*resp.Items) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + p.Info("No servers found for project %q\n", projectLabel) + return nil + } + + // Truncate output + items := *resp.Items + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(p, model.OutputFormat, items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Bool(detailsFlag, false, "Show detailed information about server") +} + +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, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Details: flags.FlagToBoolValue(p, cmd, detailsFlag), + } + + 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 buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServersRequest { + req := apiClient.ListServers(ctx, model.ProjectId) + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) + } + + if model.Details { + req = req.Details(true) + } + + return req +} + +func outputResult(p *print.Printer, outputFormat string, servers []iaas.Server) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(servers, "", " ") + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(servers, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "Name", "Status", "Availability Zones") + + for _, server := range servers { + table.AddRow(*server.Id, *server.Name, *server.Status, *server.AvailabilityZone) + table.AddSeparator() + } + + p.Outputln(table.Render()) + return nil + } +} diff --git a/internal/cmd/beta/server/list/list_test.go b/internal/cmd/beta/server/list/list_test.go new file mode 100644 index 000000000..01282379b --- /dev/null +++ b/internal/cmd/beta/server/list/list_test.go @@ -0,0 +1,217 @@ +package list + +import ( + "context" + "testing" + + "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/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testLabelSelector = "label" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + labelSelectorFlag: testLabelSelector, + detailsFlag: "true", + } + 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, + }, + Limit: utils.Ptr(int64(10)), + LabelSelector: utils.Ptr(testLabelSelector), + Details: true, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListServersRequest)) iaas.ApiListServersRequest { + request := testClient.ListServers(testCtx, testProjectId) + request = request.LabelSelector(testLabelSelector) + request = request.Details(true) + 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: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "label selector empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelSelectorFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("") + }), + }, + { + description: "details flag false", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[detailsFlag] = "false" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Details = 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.ValidateRequiredFlags() + if 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 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.ApiListServersRequest + }{ + { + 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/server/server.go b/internal/cmd/beta/server/server.go index 4a0d70559..d3debf967 100644 --- a/internal/cmd/beta/server/server.go +++ b/internal/cmd/beta/server/server.go @@ -3,6 +3,11 @@ package server import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/backup" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/command" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -25,4 +30,9 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(backup.NewCmd(p)) cmd.AddCommand(command.NewCmd(p)) + cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(update.NewCmd(p)) } diff --git a/internal/cmd/beta/server/update/update.go b/internal/cmd/beta/server/update/update.go new file mode 100644 index 000000000..127cb67ca --- /dev/null +++ b/internal/cmd/beta/server/update/update.go @@ -0,0 +1,168 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + + "github.com/spf13/cobra" + "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/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" +) + +const ( + serverIdArg = "SERVER_ID" + + nameFlag = "name" + labelFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId string + Name *string + Labels *map[string]string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates a server", + Long: "Updates a server.", + Args: args.SingleArg(serverIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update server with ID "xxx" with new name "server-1-new"`, + `$ stackit beta server update xxx --name server-1-new`, + ), + examples.NewExample( + `Update server with ID "xxx" with new name "server-1-new" and label(s)`, + `$ stackit beta server update xxx --name server-1-new --labels key=value,foo=bar`, + ), + ), + 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 + } + + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + if err != nil { + p.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update server %q?", serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update server: %w", err) + } + + return outputResult(p, model, serverLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "Server name") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + serverId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + ServerId: serverId, + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + 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 buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateServerRequest { + req := apiClient.UpdateServer(ctx, model.ProjectId, model.ServerId) + + var labelsMap *map[string]interface{} + if model.Labels != nil && len(*model.Labels) > 0 { + // convert map[string]string to map[string]interface{} + labelsMap = utils.Ptr(map[string]interface{}{}) + for k, v := range *model.Labels { + (*labelsMap)[k] = v + } + } + + payload := iaas.UpdateServerPayload{ + Name: model.Name, + Labels: labelsMap, + } + + return req.UpdateServerPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, serverLabel string, server *iaas.Server) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(server, "", " ") + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(server, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Updated server %q.\n", serverLabel) + return nil + } +} diff --git a/internal/cmd/beta/server/update/update_test.go b/internal/cmd/beta/server/update/update_test.go new file mode 100644 index 000000000..f06cbd6a5 --- /dev/null +++ b/internal/cmd/beta/server/update/update_test.go @@ -0,0 +1,252 @@ +package update + +import ( + "context" + "testing" + + "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/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testServerId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + nameFlag: "example-server-name", + projectIdFlag: testProjectId, + labelFlag: "key=value", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr("example-server-name"), + ServerId: testServerId, + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateServerRequest)) iaas.ApiUpdateServerRequest { + request := testClient.UpdateServer(testCtx, testProjectId, testServerId) + request = request.UpdateServerPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.UpdateServerPayload)) iaas.UpdateServerPayload { + payload := iaas.UpdateServerPayload{ + Name: utils.Ptr("example-server-name"), + Labels: utils.Ptr(map[string]interface{}{ + "key": "value", + }), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "use name", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nameFlag] = "example-server-name" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = utils.Ptr("example-server-name") + }), + }, + { + description: "use labels", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelFlag] = "key=value" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{ + "key": "value", + } + }), + }, + } + + 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.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + 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.ApiUpdateServerRequest + }{ + { + 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/pkg/services/iaas/utils/utils.go b/internal/pkg/services/iaas/utils/utils.go index 9e7d79510..c522633d5 100644 --- a/internal/pkg/services/iaas/utils/utils.go +++ b/internal/pkg/services/iaas/utils/utils.go @@ -8,6 +8,7 @@ import ( ) type IaaSClient interface { + GetServerExecute(ctx context.Context, projectId, serverId string) (*iaas.Server, error) GetVolumeExecute(ctx context.Context, projectId, volumeId string) (*iaas.Volume, error) GetNetworkExecute(ctx context.Context, projectId, networkId string) (*iaas.Network, error) GetNetworkAreaExecute(ctx context.Context, organizationId, areaId string) (*iaas.NetworkArea, error) @@ -15,6 +16,14 @@ type IaaSClient interface { GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, networkRangeId string) (*iaas.NetworkRange, error) } +func GetServerName(ctx context.Context, apiClient IaaSClient, projectId, serverId string) (string, error) { + resp, err := apiClient.GetServerExecute(ctx, projectId, serverId) + if err != nil { + return "", fmt.Errorf("get server: %w", err) + } + return *resp.Name, nil +} + func GetVolumeName(ctx context.Context, apiClient IaaSClient, projectId, volumeId string) (string, error) { resp, err := apiClient.GetVolumeExecute(ctx, projectId, volumeId) if err != nil { diff --git a/internal/pkg/services/iaas/utils/utils_test.go b/internal/pkg/services/iaas/utils/utils_test.go index 9c0e41ac1..aeda9d6d3 100644 --- a/internal/pkg/services/iaas/utils/utils_test.go +++ b/internal/pkg/services/iaas/utils/utils_test.go @@ -11,6 +11,8 @@ import ( ) type IaaSClientMocked struct { + GetServerFails bool + GetServerResp *iaas.Server GetVolumeFails bool GetVolumeResp *iaas.Volume GetNetworkFails bool @@ -23,6 +25,13 @@ type IaaSClientMocked struct { GetNetworkAreaRangeResp *iaas.NetworkRange } +func (m *IaaSClientMocked) GetServerExecute(_ context.Context, _, _ string) (*iaas.Server, error) { + if m.GetServerFails { + return nil, fmt.Errorf("could not get server") + } + return m.GetServerResp, nil +} + func (m *IaaSClientMocked) GetVolumeExecute(_ context.Context, _, _ string) (*iaas.Volume, error) { if m.GetVolumeFails { return nil, fmt.Errorf("could not get volume") @@ -58,6 +67,52 @@ func (m *IaaSClientMocked) GetNetworkAreaRangeExecute(_ context.Context, _, _, _ return m.GetNetworkAreaRangeResp, nil } +func TestGetServerName(t *testing.T) { + type args struct { + getInstanceFails bool + getInstanceResp *iaas.Server + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "base", + args: args{ + getInstanceResp: &iaas.Server{ + Name: utils.Ptr("test"), + }, + }, + want: "test", + }, + { + name: "get server fails", + args: args{ + getInstanceFails: true, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &IaaSClientMocked{ + GetServerFails: tt.args.getInstanceFails, + GetServerResp: tt.args.getInstanceResp, + } + got, err := GetServerName(context.Background(), m, "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetServerName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetServerName() = %v, want %v", got, tt.want) + } + }) + } +} + func TestGetVolumeName(t *testing.T) { type args struct { getInstanceFails bool From ff7220b4e947bf49fecbef418239cad5e2c731d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 19 Nov 2024 13:55:54 +0100 Subject: [PATCH 02/21] fix linter issues --- internal/cmd/beta/server/describe/describe.go | 10 +++------- internal/cmd/beta/server/list/list.go | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/cmd/beta/server/describe/describe.go b/internal/cmd/beta/server/describe/describe.go index 05c44ec5a..733efc9eb 100644 --- a/internal/cmd/beta/server/describe/describe.go +++ b/internal/cmd/beta/server/describe/describe.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "strings" "github.com/goccy/go-yaml" @@ -12,6 +11,7 @@ import ( "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" @@ -191,17 +191,13 @@ func outputResult(p *print.Printer, outputFormat string, server *iaas.Server) er if server.ServiceAccountMails != nil && len(*server.ServiceAccountMails) > 0 { emails := []string{} - for _, email := range *server.ServiceAccountMails { - emails = append(emails, email) - } + emails = append(emails, *server.ServiceAccountMails...) table.AddRow("SERVICE ACCOUNTS", strings.Join(emails, "\n")) } if server.Volumes != nil && len(*server.Volumes) > 0 { volumes := []string{} - for _, volume := range *server.Volumes { - volumes = append(volumes, volume) - } + volumes = append(volumes, *server.Volumes...) table.AddRow("VOLUMES", strings.Join(volumes, "\n")) } diff --git a/internal/cmd/beta/server/list/list.go b/internal/cmd/beta/server/list/list.go index b8cb7b879..e946110a3 100644 --- a/internal/cmd/beta/server/list/list.go +++ b/internal/cmd/beta/server/list/list.go @@ -178,9 +178,9 @@ func outputResult(p *print.Printer, outputFormat string, servers []iaas.Server) table := tables.NewTable() table.SetHeader("ID", "Name", "Status", "Availability Zones") - for _, server := range servers { + for i := range servers { + server := servers[i] table.AddRow(*server.Id, *server.Name, *server.Status, *server.AvailabilityZone) - table.AddSeparator() } p.Outputln(table.Render()) From 96e2492640ef09279e9fa6932994aaa0528d7d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 11:44:48 +0100 Subject: [PATCH 03/21] change --details flag handling --- docs/stackit_beta_server_list.md | 4 -- internal/cmd/beta/server/describe/describe.go | 45 ++++++++++++------- internal/cmd/beta/server/list/list.go | 12 ----- internal/cmd/beta/server/list/list_test.go | 13 ------ 4 files changed, 29 insertions(+), 45 deletions(-) diff --git a/docs/stackit_beta_server_list.md b/docs/stackit_beta_server_list.md index 3a4329380..4641bea02 100644 --- a/docs/stackit_beta_server_list.md +++ b/docs/stackit_beta_server_list.md @@ -19,9 +19,6 @@ stackit beta server list [flags] Lists all servers which contains the label xxx $ stackit beta server list --label-selector xxx - Lists all servers with detailed information - $ stackit beta server list --details - Lists all servers in JSON format $ stackit beta server list --output-format json @@ -32,7 +29,6 @@ stackit beta server list [flags] ### Options ``` - --details Show detailed information about server -h, --help Help for "stackit beta server list" --label-selector string Filter by label --limit int Maximum number of entries to list diff --git a/internal/cmd/beta/server/describe/describe.go b/internal/cmd/beta/server/describe/describe.go index 733efc9eb..12b534a42 100644 --- a/internal/cmd/beta/server/describe/describe.go +++ b/internal/cmd/beta/server/describe/describe.go @@ -73,7 +73,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(p, model, resp) }, } configureFlags(cmd) @@ -120,7 +120,9 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli return req } -func outputResult(p *print.Printer, outputFormat string, server *iaas.Server) error { +func outputResult(p *print.Printer, model *inputModel, server *iaas.Server) error { + outputFormat := model.OutputFormat + switch outputFormat { case print.JSONOutputFormat: details, err := json.MarshalIndent(server, "", " ") @@ -173,32 +175,43 @@ func outputResult(p *print.Printer, outputFormat string, server *iaas.Server) er table.AddSeparator() } - if server.Nics != nil && len(*server.Nics) > 0 { - nics := []string{} - for _, nic := range *server.Nics { - nics = append(nics, *nic.NicId) - } - table.AddRow("NICS", strings.Join(nics, "\n")) - } - if server.Labels != nil && len(*server.Labels) > 0 { labels := []string{} for key, value := range *server.Labels { labels = append(labels, fmt.Sprintf("%s: %s", key, value)) } table.AddRow("LABELS", strings.Join(labels, "\n")) - } - - if server.ServiceAccountMails != nil && len(*server.ServiceAccountMails) > 0 { - emails := []string{} - emails = append(emails, *server.ServiceAccountMails...) - table.AddRow("SERVICE ACCOUNTS", strings.Join(emails, "\n")) + table.AddSeparator() } if server.Volumes != nil && len(*server.Volumes) > 0 { volumes := []string{} volumes = append(volumes, *server.Volumes...) table.AddRow("VOLUMES", strings.Join(volumes, "\n")) + table.AddSeparator() + } + + if model.Details { + if server.ServiceAccountMails != nil && len(*server.ServiceAccountMails) > 0 { + emails := []string{} + emails = append(emails, *server.ServiceAccountMails...) + table.AddRow("SERVICE ACCOUNTS", strings.Join(emails, "\n")) + table.AddSeparator() + } + + if server.Nics != nil && len(*server.Nics) > 0 { + nics := []string{} + for _, nic := range *server.Nics { + nics = append(nics, *nic.NicId) + } + table.AddRow("NICS", strings.Join(nics, "\n")) + table.AddSeparator() + } + + if server.UserData != nil { + table.AddRow("USER DATA", *server.UserData) + table.AddSeparator() + } } err := table.Display(p) diff --git a/internal/cmd/beta/server/list/list.go b/internal/cmd/beta/server/list/list.go index e946110a3..b872a354e 100644 --- a/internal/cmd/beta/server/list/list.go +++ b/internal/cmd/beta/server/list/list.go @@ -23,14 +23,12 @@ import ( const ( limitFlag = "limit" labelSelectorFlag = "label-selector" - detailsFlag = "details" ) type inputModel struct { *globalflags.GlobalFlagModel Limit *int64 LabelSelector *string - Details bool } func NewCmd(p *print.Printer) *cobra.Command { @@ -48,10 +46,6 @@ func NewCmd(p *print.Printer) *cobra.Command { `Lists all servers which contains the label xxx`, "$ stackit beta server list --label-selector xxx", ), - examples.NewExample( - `Lists all servers with detailed information`, - "$ stackit beta server list --details", - ), examples.NewExample( `Lists all servers in JSON format`, "$ stackit beta server list --output-format json", @@ -107,7 +101,6 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") cmd.Flags().String(labelSelectorFlag, "", "Filter by label") - cmd.Flags().Bool(detailsFlag, false, "Show detailed information about server") } func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { @@ -128,7 +121,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, Limit: limit, LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), - Details: flags.FlagToBoolValue(p, cmd, detailsFlag), } if p.IsVerbosityDebug() { @@ -149,10 +141,6 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli req = req.LabelSelector(*model.LabelSelector) } - if model.Details { - req = req.Details(true) - } - return req } diff --git a/internal/cmd/beta/server/list/list_test.go b/internal/cmd/beta/server/list/list_test.go index 01282379b..47ed2e4f1 100644 --- a/internal/cmd/beta/server/list/list_test.go +++ b/internal/cmd/beta/server/list/list_test.go @@ -28,7 +28,6 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st projectIdFlag: testProjectId, limitFlag: "10", labelSelectorFlag: testLabelSelector, - detailsFlag: "true", } for _, mod := range mods { mod(flagValues) @@ -44,7 +43,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), - Details: true, } for _, mod := range mods { mod(model) @@ -55,7 +53,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func fixtureRequest(mods ...func(request *iaas.ApiListServersRequest)) iaas.ApiListServersRequest { request := testClient.ListServers(testCtx, testProjectId) request = request.LabelSelector(testLabelSelector) - request = request.Details(true) for _, mod := range mods { mod(&request) } @@ -130,16 +127,6 @@ func TestParseInput(t *testing.T) { model.LabelSelector = utils.Ptr("") }), }, - { - description: "details flag false", - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[detailsFlag] = "false" - }), - isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.Details = false - }), - }, } for _, tt := range tests { From 163872a076fcd4733c55fb842c8d570019966d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 11:45:10 +0100 Subject: [PATCH 04/21] remove userData from table output in describe command --- internal/cmd/beta/server/describe/describe.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/cmd/beta/server/describe/describe.go b/internal/cmd/beta/server/describe/describe.go index 12b534a42..a9c105a93 100644 --- a/internal/cmd/beta/server/describe/describe.go +++ b/internal/cmd/beta/server/describe/describe.go @@ -207,11 +207,6 @@ func outputResult(p *print.Printer, model *inputModel, server *iaas.Server) erro table.AddRow("NICS", strings.Join(nics, "\n")) table.AddSeparator() } - - if server.UserData != nil { - table.AddRow("USER DATA", *server.UserData) - table.AddSeparator() - } } err := table.Display(p) From f5ea206b55012a461c246aae5d937609ddb0a20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 11:55:01 +0100 Subject: [PATCH 05/21] fix linter issues --- internal/cmd/beta/server/create/create.go | 2 +- internal/cmd/beta/server/list/list.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 44fa6b16a..d9faac7e8 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -85,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64`, ), ), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() model, err := parseInput(p, cmd) if err != nil { diff --git a/internal/cmd/beta/server/list/list.go b/internal/cmd/beta/server/list/list.go index b872a354e..af2e59655 100644 --- a/internal/cmd/beta/server/list/list.go +++ b/internal/cmd/beta/server/list/list.go @@ -55,7 +55,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit beta server list --limit 10", ), ), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() model, err := parseInput(p, cmd) if err != nil { From 940712f06afb90f9ddb9227b2fac2c57e9c9d891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 14:47:34 +0100 Subject: [PATCH 06/21] update descriptions --- docs/stackit_beta_server_create.md | 12 ++++++------ internal/cmd/beta/server/create/create.go | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/stackit_beta_server_create.md b/docs/stackit_beta_server_create.md index b6d95b7ce..502a37058 100644 --- a/docs/stackit_beta_server_create.md +++ b/docs/stackit_beta_server_create.md @@ -27,23 +27,23 @@ stackit beta server create [flags] ``` --affinity-group string The affinity group the server is assigned to - --availability-zone string Availability zone + --availability-zone string The availability zone of the server --boot-volume-delete-on-termination Delete the volume during the termination of the server. Defaults to false --boot-volume-performance-class string Boot volume performance class - --boot-volume-size int Boot volume size (GB). Size is required for the image type boot volumes + --boot-volume-size source_type The size of the boot volume in GB. Must be provided when source_type is `image` --boot-volume-source-id string ID of the source object of boot volume. It can be either 'image-id' or 'volume-id' --boot-volume-source-type string Type of the source object of boot volume. It can be either 'image' or 'volume' -h, --help Help for "stackit beta server create" - --image-id string ID of the image. Either image-id or boot volume is required - --keypair-name string The SSH keypair used during the server creation + --image-id string The image ID to be used for an ephemeral disk on the server. Either image-id or boot volume is required + --keypair-name string The name of the SSH keypair used during the server creation --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default []) - --machine-type string Machine type the server shall belong to + --machine-type string Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html) -n, --name string Server name --network-id string ID of the network for the initial networking setup for the server creation --network-interface-ids strings List of network interface IDs for the initial networking setup for the server creation --security-groups strings The initial security groups for the server creation --service-account-emails strings List of the service account mails - --user-data string User data that is provided to the server + --user-data string User data that is passed via cloud-init to the server --volumes strings The list of volumes attached to the server ``` diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index d9faac7e8..bd50c1ca5 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -140,22 +140,22 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(nameFlag, "n", "", "Server name") - cmd.Flags().String(machineTypeFlag, "", "Machine type the server shall belong to") + cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)") cmd.Flags().String(affinityGroupFlag, "", "The affinity group the server is assigned to") - cmd.Flags().String(availabilityZoneFlag, "", "Availability zone") + cmd.Flags().String(availabilityZoneFlag, "", "The availability zone of the server") cmd.Flags().String(bootVolumeSourceIdFlag, "", "ID of the source object of boot volume. It can be either 'image-id' or 'volume-id'") cmd.Flags().String(bootVolumeSourceTypeFlag, "", "Type of the source object of boot volume. It can be either 'image' or 'volume'") - cmd.Flags().Int64(bootVolumeSizeFlag, 0, "Boot volume size (GB). Size is required for the image type boot volumes") + cmd.Flags().Int64(bootVolumeSizeFlag, 0, "The size of the boot volume in GB. Must be provided when `source_type` is `image`") cmd.Flags().String(bootVolumePerformanceClassFlag, "", "Boot volume performance class") cmd.Flags().Bool(bootVolumeDeleteOnTerminationFlag, false, "Delete the volume during the termination of the server. Defaults to false") - cmd.Flags().String(imageIdFlag, "", "ID of the image. Either image-id or boot volume is required") - cmd.Flags().String(keypairNameFlag, "", "The SSH keypair used during the server creation") + cmd.Flags().String(imageIdFlag, "", "The image ID to be used for an ephemeral disk on the server. Either image-id or boot volume is required") + cmd.Flags().String(keypairNameFlag, "", "The name of the SSH keypair used during the server creation") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'") cmd.Flags().String(networkIdFlag, "", "ID of the network for the initial networking setup for the server creation") cmd.Flags().StringSlice(networkInterfaceIdsFlag, []string{}, "List of network interface IDs for the initial networking setup for the server creation") cmd.Flags().StringSlice(securityGroupsFlag, []string{}, "The initial security groups for the server creation") cmd.Flags().StringSlice(serviceAccountEmailsFlag, []string{}, "List of the service account mails") - cmd.Flags().String(userDataFlag, "", "User data that is provided to the server") + cmd.Flags().String(userDataFlag, "", "User data that is passed via cloud-init to the server") cmd.Flags().StringSlice(volumesFlag, []string{}, "The list of volumes attached to the server") err := flags.MarkFlagsRequired(cmd, nameFlag, machineTypeFlag) From 100e37adc78c2c8a6fb3967d8036bc4c46a23815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 15:50:14 +0100 Subject: [PATCH 07/21] make flags conditionally required --- internal/cmd/beta/server/create/create.go | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index bd50c1ca5..02410f123 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -85,6 +85,36 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64`, ), ), + PreRun: func(cmd *cobra.Command, _ []string) { + bootVolumeSourceId, _ := cmd.Flags().GetString(bootVolumeSourceIdFlag) + bootVolumeSourceType, _ := cmd.Flags().GetString(bootVolumeSourceTypeFlag) + bootVolumeSize, _ := cmd.Flags().GetInt64(bootVolumeSizeFlag) + imageId, _ := cmd.Flags().GetString(imageIdFlag) + + if imageId == "" && bootVolumeSourceId == "" && bootVolumeSourceType == "" { + p.Warn("Either Image ID or boot volume flags must be provided.\n") + } + + if imageId == "" { + err := flags.MarkFlagsRequired(cmd, bootVolumeSourceIdFlag, bootVolumeSourceTypeFlag) + cobra.CheckErr(err) + } + + if bootVolumeSourceType == "image" { + if bootVolumeSize == 0 { + p.Warn("Boot volume size must be provided when `source_type` is `image`.\n") + } + cmd.MarkFlagRequired(bootVolumeSizeFlag) + } + + if bootVolumeSourceId == "" && bootVolumeSourceType == "" { + cmd.MarkFlagRequired(imageIdFlag) + } + + if imageId != "" && (bootVolumeSourceId != "" || bootVolumeSourceType != "") { + p.Warn("Image ID flag cannot be used together with any of the boot volume flags.\n") + } + }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() model, err := parseInput(p, cmd) From 5f34d3a618c61c23946eb64ece4745b45292e17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 16:04:12 +0100 Subject: [PATCH 08/21] add more examples --- docs/stackit_beta_server_create.md | 24 +++++++++++++++--- internal/cmd/beta/server/create/create.go | 30 ++++++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/docs/stackit_beta_server_create.md b/docs/stackit_beta_server_create.md index 502a37058..5bac856dd 100644 --- a/docs/stackit_beta_server_create.md +++ b/docs/stackit_beta_server_create.md @@ -13,14 +13,32 @@ stackit beta server create [flags] ### Examples ``` - Create a server with machine type "t1.1", name "server1" and image with id xxx + Create a server from an image with id xxx $ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx - Create a server with machine type "t1.1", name "server1", image with id xxx and labels + Create a server with labels from an image with id xxx $ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --labels key=value,foo=bar - Create a server with machine type "t1.1", name "server1", boot volume source id "xxx", type "image" and size 64GB + Create a server with a boot volume $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 + + Create a server with a boot volume from an existing volume + $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type volume + + Create a server with a keypair + $ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --keypair-name example + + Create a server with a network + $ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --network-id yyy + + Create a server with a network interface + $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --network-interface-ids yyy + + Create a server with an attached volume + $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --volumes yyy + + Create a server with user data (cloud-init) + $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data file("${path.module}/cloud-init.yaml") ``` ### Options diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 02410f123..84497c742 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -73,17 +73,41 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Create a server with machine type "t1.1", name "server1" and image with id xxx`, + `Create a server from an image with id xxx`, `$ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx`, ), examples.NewExample( - `Create a server with machine type "t1.1", name "server1", image with id xxx and labels`, + `Create a server with labels from an image with id xxx`, `$ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --labels key=value,foo=bar`, ), examples.NewExample( - `Create a server with machine type "t1.1", name "server1", boot volume source id "xxx", type "image" and size 64GB`, + `Create a server with a boot volume`, `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64`, ), + examples.NewExample( + `Create a server with a boot volume from an existing volume`, + `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type volume`, + ), + examples.NewExample( + `Create a server with a keypair`, + `$ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --keypair-name example`, + ), + examples.NewExample( + `Create a server with a network`, + `$ stackit beta server create --machine-type t1.1 --name server1 --image-id xxx --network-id yyy`, + ), + examples.NewExample( + `Create a server with a network interface`, + `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --network-interface-ids yyy`, + ), + examples.NewExample( + `Create a server with an attached volume`, + `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --volumes yyy`, + ), + examples.NewExample( + `Create a server with user data (cloud-init)`, + `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data file("${path.module}/cloud-init.yaml")`, + ), ), PreRun: func(cmd *cobra.Command, _ []string) { bootVolumeSourceId, _ := cmd.Flags().GetString(bootVolumeSourceIdFlag) From 018f1b0947a9f869b8219a71624b5bee251cfdd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Thu, 21 Nov 2024 16:10:54 +0100 Subject: [PATCH 09/21] fix linter issues --- internal/cmd/beta/server/create/create.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 84497c742..e38092c5d 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -128,11 +128,13 @@ func NewCmd(p *print.Printer) *cobra.Command { if bootVolumeSize == 0 { p.Warn("Boot volume size must be provided when `source_type` is `image`.\n") } - cmd.MarkFlagRequired(bootVolumeSizeFlag) + err := cmd.MarkFlagRequired(bootVolumeSizeFlag) + cobra.CheckErr(err) } if bootVolumeSourceId == "" && bootVolumeSourceType == "" { - cmd.MarkFlagRequired(imageIdFlag) + err := cmd.MarkFlagRequired(imageIdFlag) + cobra.CheckErr(err) } if imageId != "" && (bootVolumeSourceId != "" || bootVolumeSourceType != "") { From a5800b91100220019ef7b957ecefdd3016aad386 Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:11:28 +0100 Subject: [PATCH 10/21] Update internal/cmd/beta/server/create/create.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Palet --- internal/cmd/beta/server/create/create.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index e38092c5d..b148ab0cd 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -199,12 +199,12 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)") cmd.Flags().String(affinityGroupFlag, "", "The affinity group the server is assigned to") cmd.Flags().String(availabilityZoneFlag, "", "The availability zone of the server") - cmd.Flags().String(bootVolumeSourceIdFlag, "", "ID of the source object of boot volume. It can be either 'image-id' or 'volume-id'") + cmd.Flags().String(bootVolumeSourceIdFlag, "", "ID of the source object of boot volume. It can be either an image or volume ID") cmd.Flags().String(bootVolumeSourceTypeFlag, "", "Type of the source object of boot volume. It can be either 'image' or 'volume'") - cmd.Flags().Int64(bootVolumeSizeFlag, 0, "The size of the boot volume in GB. Must be provided when `source_type` is `image`") + cmd.Flags().Int64(bootVolumeSizeFlag, 0, "The size of the boot volume in GB. Must be provided when 'boot-volume-source-type' is 'image'") cmd.Flags().String(bootVolumePerformanceClassFlag, "", "Boot volume performance class") cmd.Flags().Bool(bootVolumeDeleteOnTerminationFlag, false, "Delete the volume during the termination of the server. Defaults to false") - cmd.Flags().String(imageIdFlag, "", "The image ID to be used for an ephemeral disk on the server. Either image-id or boot volume is required") + cmd.Flags().String(imageIdFlag, "", "The image ID to be used for an ephemeral disk on the server. Either 'image-id' or 'boot-volume-...' flags are required") cmd.Flags().String(keypairNameFlag, "", "The name of the SSH keypair used during the server creation") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'") cmd.Flags().String(networkIdFlag, "", "ID of the network for the initial networking setup for the server creation") From 15c7fe50d116f030f83eec61dbd4a8c59f89038e Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:20:02 +0100 Subject: [PATCH 11/21] Update internal/cmd/beta/server/create/create.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Palet --- internal/cmd/beta/server/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index b148ab0cd..3cf80bd01 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -106,7 +106,7 @@ func NewCmd(p *print.Printer) *cobra.Command { ), examples.NewExample( `Create a server with user data (cloud-init)`, - `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data file("${path.module}/cloud-init.yaml")`, + `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml")`, ), ), PreRun: func(cmd *cobra.Command, _ []string) { From b2e590f8250718ea0bae6f934dd8703de65d7019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 25 Nov 2024 11:10:10 +0100 Subject: [PATCH 12/21] adapt link in the create flags --- docs/stackit_beta_server_create.md | 2 +- internal/cmd/beta/server/create/create.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/stackit_beta_server_create.md b/docs/stackit_beta_server_create.md index 5bac856dd..03ae41106 100644 --- a/docs/stackit_beta_server_create.md +++ b/docs/stackit_beta_server_create.md @@ -55,7 +55,7 @@ stackit beta server create [flags] --image-id string The image ID to be used for an ephemeral disk on the server. Either image-id or boot volume is required --keypair-name string The name of the SSH keypair used during the server creation --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default []) - --machine-type string Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html) + --machine-type string Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html -n, --name string Server name --network-id string ID of the network for the initial networking setup for the server creation --network-interface-ids strings List of network interface IDs for the initial networking setup for the server creation diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 3cf80bd01..59fd4694c 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -196,7 +196,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(nameFlag, "n", "", "Server name") - cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)") + cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html") cmd.Flags().String(affinityGroupFlag, "", "The affinity group the server is assigned to") cmd.Flags().String(availabilityZoneFlag, "", "The availability zone of the server") cmd.Flags().String(bootVolumeSourceIdFlag, "", "ID of the source object of boot volume. It can be either an image or volume ID") From a2feccf67a3b10509821569c93148b811eb236d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 25 Nov 2024 11:19:30 +0100 Subject: [PATCH 13/21] update docs --- docs/stackit_beta_server_create.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/stackit_beta_server_create.md b/docs/stackit_beta_server_create.md index 03ae41106..7d57224db 100644 --- a/docs/stackit_beta_server_create.md +++ b/docs/stackit_beta_server_create.md @@ -48,11 +48,11 @@ stackit beta server create [flags] --availability-zone string The availability zone of the server --boot-volume-delete-on-termination Delete the volume during the termination of the server. Defaults to false --boot-volume-performance-class string Boot volume performance class - --boot-volume-size source_type The size of the boot volume in GB. Must be provided when source_type is `image` - --boot-volume-source-id string ID of the source object of boot volume. It can be either 'image-id' or 'volume-id' + --boot-volume-size int The size of the boot volume in GB. Must be provided when 'boot-volume-source-type' is 'image' + --boot-volume-source-id string ID of the source object of boot volume. It can be either an image or volume ID --boot-volume-source-type string Type of the source object of boot volume. It can be either 'image' or 'volume' -h, --help Help for "stackit beta server create" - --image-id string The image ID to be used for an ephemeral disk on the server. Either image-id or boot volume is required + --image-id string The image ID to be used for an ephemeral disk on the server. Either 'image-id' or 'boot-volume-...' flags are required --keypair-name string The name of the SSH keypair used during the server creation --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default []) --machine-type string Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html From d60abe3eae2bb38b2748e69851d6e57a7820d66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 25 Nov 2024 11:46:19 +0100 Subject: [PATCH 14/21] change type of userDataFlag --- docs/stackit_beta_server_create.md | 2 +- internal/cmd/beta/server/create/create.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/stackit_beta_server_create.md b/docs/stackit_beta_server_create.md index 7d57224db..6d8194715 100644 --- a/docs/stackit_beta_server_create.md +++ b/docs/stackit_beta_server_create.md @@ -38,7 +38,7 @@ stackit beta server create [flags] $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --volumes yyy Create a server with user data (cloud-init) - $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data file("${path.module}/cloud-init.yaml") + $ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml") ``` ### Options diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 59fd4694c..2b850eba2 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -211,7 +211,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().StringSlice(networkInterfaceIdsFlag, []string{}, "List of network interface IDs for the initial networking setup for the server creation") cmd.Flags().StringSlice(securityGroupsFlag, []string{}, "The initial security groups for the server creation") cmd.Flags().StringSlice(serviceAccountEmailsFlag, []string{}, "List of the service account mails") - cmd.Flags().String(userDataFlag, "", "User data that is passed via cloud-init to the server") + cmd.Flags().Var(flags.ReadFromFileFlag(), userDataFlag, "User data that is passed via cloud-init to the server") cmd.Flags().StringSlice(volumesFlag, []string{}, "The list of volumes attached to the server") err := flags.MarkFlagsRequired(cmd, nameFlag, machineTypeFlag) From b10d11f4dee21eff263c86b829114cd955c30192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 25 Nov 2024 14:18:52 +0100 Subject: [PATCH 15/21] change the flag validation --- internal/cmd/beta/server/create/create.go | 67 ++++++++++--------- .../cmd/beta/server/create/create_test.go | 2 - internal/pkg/errors/errors.go | 20 ++++++ 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 2b850eba2..37bdc21ad 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -109,38 +109,6 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml")`, ), ), - PreRun: func(cmd *cobra.Command, _ []string) { - bootVolumeSourceId, _ := cmd.Flags().GetString(bootVolumeSourceIdFlag) - bootVolumeSourceType, _ := cmd.Flags().GetString(bootVolumeSourceTypeFlag) - bootVolumeSize, _ := cmd.Flags().GetInt64(bootVolumeSizeFlag) - imageId, _ := cmd.Flags().GetString(imageIdFlag) - - if imageId == "" && bootVolumeSourceId == "" && bootVolumeSourceType == "" { - p.Warn("Either Image ID or boot volume flags must be provided.\n") - } - - if imageId == "" { - err := flags.MarkFlagsRequired(cmd, bootVolumeSourceIdFlag, bootVolumeSourceTypeFlag) - cobra.CheckErr(err) - } - - if bootVolumeSourceType == "image" { - if bootVolumeSize == 0 { - p.Warn("Boot volume size must be provided when `source_type` is `image`.\n") - } - err := cmd.MarkFlagRequired(bootVolumeSizeFlag) - cobra.CheckErr(err) - } - - if bootVolumeSourceId == "" && bootVolumeSourceType == "" { - err := cmd.MarkFlagRequired(imageIdFlag) - cobra.CheckErr(err) - } - - if imageId != "" && (bootVolumeSourceId != "" || bootVolumeSourceType != "") { - p.Warn("Image ID flag cannot be used together with any of the boot volume flags.\n") - } - }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() model, err := parseInput(p, cmd) @@ -215,6 +183,9 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().StringSlice(volumesFlag, []string{}, "The list of volumes attached to the server") err := flags.MarkFlagsRequired(cmd, nameFlag, machineTypeFlag) + cmd.MarkFlagsMutuallyExclusive(imageIdFlag, bootVolumeSourceIdFlag) + cmd.MarkFlagsMutuallyExclusive(imageIdFlag, bootVolumeSourceTypeFlag) + cmd.MarkFlagsMutuallyExclusive(networkIdFlag, networkInterfaceIdsFlag) cobra.CheckErr(err) } @@ -224,6 +195,35 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return nil, &cliErr.ProjectIdError{} } + bootVolumeSourceId, _ := cmd.Flags().GetString(bootVolumeSourceIdFlag) + bootVolumeSourceType, _ := cmd.Flags().GetString(bootVolumeSourceTypeFlag) + bootVolumeSize, _ := cmd.Flags().GetInt64(bootVolumeSizeFlag) + imageId, _ := cmd.Flags().GetString(imageIdFlag) + + if imageId == "" && bootVolumeSourceId == "" && bootVolumeSourceType == "" { + return nil, &cliErr.ServerCreateMissingFlagsError{ + Cmd: cmd, + } + } + + if imageId == "" { + err := flags.MarkFlagsRequired(cmd, bootVolumeSourceIdFlag, bootVolumeSourceTypeFlag) + cobra.CheckErr(err) + } + + if bootVolumeSourceType == "image" && bootVolumeSize == 0 { + err := cmd.MarkFlagRequired(bootVolumeSizeFlag) + cobra.CheckErr(err) + return nil, &cliErr.ServerCreateError{ + Cmd: cmd, + } + } + + if bootVolumeSourceId == "" && bootVolumeSourceType == "" { + err := cmd.MarkFlagRequired(imageIdFlag) + cobra.CheckErr(err) + } + model := inputModel{ GlobalFlagModel: globalFlags, Name: flags.FlagToStringPointer(p, cmd, nameFlag), @@ -303,7 +303,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli payload.Networking.CreateServerNetworkingWithNics = &iaas.CreateServerNetworkingWithNics{ NicIds: model.NetworkInterfaceIds, } - } else if model.NetworkId != nil { + } + if model.NetworkId != nil { payload.Networking.CreateServerNetworking = &iaas.CreateServerNetworking{ NetworkId: model.NetworkId, } diff --git a/internal/cmd/beta/server/create/create_test.go b/internal/cmd/beta/server/create/create_test.go index 99df18e0d..353341716 100644 --- a/internal/cmd/beta/server/create/create_test.go +++ b/internal/cmd/beta/server/create/create_test.go @@ -167,7 +167,6 @@ func TestParseInput(t *testing.T) { delete(flagValues, bootVolumeSizeFlag) delete(flagValues, bootVolumePerformanceClassFlag) delete(flagValues, bootVolumeDeleteOnTerminationFlag) - delete(flagValues, imageIdFlag) delete(flagValues, keypairNameFlag) delete(flagValues, networkIdFlag) delete(flagValues, networkInterfaceIdsFlag) @@ -186,7 +185,6 @@ func TestParseInput(t *testing.T) { model.BootVolumeSize = nil model.BootVolumePerformanceClass = nil model.BootVolumeDeleteOnTermination = nil - model.ImageId = nil model.KeypairName = nil model.NetworkId = nil model.NetworkInterfaceIds = nil diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 92af7ab83..fdd38d4c0 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -138,8 +138,28 @@ The profile name can only contain lowercase letters, numbers, and "-" and cannot To enable it, run: $ stackit %s enable` + + IAAS_SERVER_MISSING_VOLUME_SIZE = `Boot volume size must be provided when "source_type" is "image".` + + IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either Image ID or boot volume flags must be provided.` ) +type ServerCreateMissingFlagsError struct { + Cmd *cobra.Command +} + +func (e *ServerCreateMissingFlagsError) Error() string { + return fmt.Sprintf(IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS) +} + +type ServerCreateError struct { + Cmd *cobra.Command +} + +func (e *ServerCreateError) Error() string { + return fmt.Sprintf(IAAS_SERVER_MISSING_VOLUME_SIZE) +} + type ProjectIdError struct{} func (e *ProjectIdError) Error() string { From aaede9439d5ec68b6d52b3aeefae2c2018e0e5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 25 Nov 2024 14:23:45 +0100 Subject: [PATCH 16/21] fix linter issues --- internal/pkg/errors/errors.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index fdd38d4c0..ac0ca33a3 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -149,7 +149,7 @@ type ServerCreateMissingFlagsError struct { } func (e *ServerCreateMissingFlagsError) Error() string { - return fmt.Sprintf(IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS) + return IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS } type ServerCreateError struct { @@ -157,7 +157,7 @@ type ServerCreateError struct { } func (e *ServerCreateError) Error() string { - return fmt.Sprintf(IAAS_SERVER_MISSING_VOLUME_SIZE) + return IAAS_SERVER_MISSING_VOLUME_SIZE } type ProjectIdError struct{} From cdd7f785d47a547d2283d46285d23801007aa848 Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:45:04 +0100 Subject: [PATCH 17/21] Update internal/pkg/errors/errors.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Palet --- internal/pkg/errors/errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index ac0ca33a3..37fd8e405 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -139,7 +139,7 @@ The profile name can only contain lowercase letters, numbers, and "-" and cannot To enable it, run: $ stackit %s enable` - IAAS_SERVER_MISSING_VOLUME_SIZE = `Boot volume size must be provided when "source_type" is "image".` + IAAS_SERVER_MISSING_VOLUME_SIZE = `The "boot-volume-size" flag must be provided when "boot-volume-source-type" is "image".` IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either Image ID or boot volume flags must be provided.` ) From a885f1ee3e5cf3401ba0e63d4e49b6d8a6faea27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 26 Nov 2024 08:49:34 +0100 Subject: [PATCH 18/21] change error message --- internal/pkg/errors/errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 37fd8e405..463ea9198 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -141,7 +141,7 @@ To enable it, run: IAAS_SERVER_MISSING_VOLUME_SIZE = `The "boot-volume-size" flag must be provided when "boot-volume-source-type" is "image".` - IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either Image ID or boot volume flags must be provided.` + IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either "image-id" or "boot-volume-source-type" and "boot-volume-source-id" flags must be provided.` ) type ServerCreateMissingFlagsError struct { From 1ff48ce21b4de61537d136ac0d1ce987d88c60f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 26 Nov 2024 09:24:24 +0100 Subject: [PATCH 19/21] improve flag handling --- internal/cmd/beta/server/create/create.go | 40 +++++++++++++++++------ internal/pkg/errors/errors.go | 20 ++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/internal/cmd/beta/server/create/create.go b/internal/cmd/beta/server/create/create.go index 37bdc21ad..106956633 100644 --- a/internal/cmd/beta/server/create/create.go +++ b/internal/cmd/beta/server/create/create.go @@ -195,31 +195,51 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return nil, &cliErr.ProjectIdError{} } - bootVolumeSourceId, _ := cmd.Flags().GetString(bootVolumeSourceIdFlag) - bootVolumeSourceType, _ := cmd.Flags().GetString(bootVolumeSourceTypeFlag) - bootVolumeSize, _ := cmd.Flags().GetInt64(bootVolumeSizeFlag) - imageId, _ := cmd.Flags().GetString(imageIdFlag) + bootVolumeSourceId := flags.FlagToStringPointer(p, cmd, bootVolumeSourceIdFlag) + bootVolumeSourceType := flags.FlagToStringPointer(p, cmd, bootVolumeSourceTypeFlag) + bootVolumeSize := flags.FlagToInt64Pointer(p, cmd, bootVolumeSizeFlag) + imageId := flags.FlagToStringPointer(p, cmd, imageIdFlag) - if imageId == "" && bootVolumeSourceId == "" && bootVolumeSourceType == "" { + if imageId == nil && bootVolumeSourceId == nil && bootVolumeSourceType == nil { return nil, &cliErr.ServerCreateMissingFlagsError{ Cmd: cmd, } } - if imageId == "" { + if imageId == nil { err := flags.MarkFlagsRequired(cmd, bootVolumeSourceIdFlag, bootVolumeSourceTypeFlag) cobra.CheckErr(err) } - if bootVolumeSourceType == "image" && bootVolumeSize == 0 { - err := cmd.MarkFlagRequired(bootVolumeSizeFlag) + if bootVolumeSourceId != nil && bootVolumeSourceType == nil { + err := cmd.MarkFlagRequired(bootVolumeSourceTypeFlag) cobra.CheckErr(err) - return nil, &cliErr.ServerCreateError{ + + return nil, &cliErr.ServerCreateMissingVolumeTypeError{ Cmd: cmd, } } - if bootVolumeSourceId == "" && bootVolumeSourceType == "" { + if bootVolumeSourceType != nil { + if bootVolumeSourceId == nil { + err := cmd.MarkFlagRequired(bootVolumeSourceIdFlag) + cobra.CheckErr(err) + + return nil, &cliErr.ServerCreateMissingVolumeIdError{ + Cmd: cmd, + } + } + + if *bootVolumeSourceType == "image" && bootVolumeSize == nil { + err := cmd.MarkFlagRequired(bootVolumeSizeFlag) + cobra.CheckErr(err) + return nil, &cliErr.ServerCreateError{ + Cmd: cmd, + } + } + } + + if bootVolumeSourceId == nil && bootVolumeSourceType == nil { err := cmd.MarkFlagRequired(imageIdFlag) cobra.CheckErr(err) } diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 463ea9198..0f37aa617 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -141,9 +141,29 @@ To enable it, run: IAAS_SERVER_MISSING_VOLUME_SIZE = `The "boot-volume-size" flag must be provided when "boot-volume-source-type" is "image".` + IAAS_SERVER_MISSING_VOLUME_ID = `The "boot-volume-source-id" flag must be provided together with "boot-volume-source-type" flag.` + + IAAS_SERVER_MISSING_VOLUME_TYPE = `The "boot-volume-source-type" flag must be provided together with "boot-volume-source-id" flag.` + IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either "image-id" or "boot-volume-source-type" and "boot-volume-source-id" flags must be provided.` ) +type ServerCreateMissingVolumeIdError struct { + Cmd *cobra.Command +} + +func (e *ServerCreateMissingVolumeIdError) Error() string { + return IAAS_SERVER_MISSING_VOLUME_ID +} + +type ServerCreateMissingVolumeTypeError struct { + Cmd *cobra.Command +} + +func (e *ServerCreateMissingVolumeTypeError) Error() string { + return IAAS_SERVER_MISSING_VOLUME_TYPE +} + type ServerCreateMissingFlagsError struct { Cmd *cobra.Command } From 962320ffdde3b2d5bab7d7849e91cadf64d55939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 26 Nov 2024 10:32:20 +0100 Subject: [PATCH 20/21] extend unit tests --- .../cmd/beta/server/create/create_test.go | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/internal/cmd/beta/server/create/create_test.go b/internal/cmd/beta/server/create/create_test.go index 353341716..e4b4d99f3 100644 --- a/internal/cmd/beta/server/create/create_test.go +++ b/internal/cmd/beta/server/create/create_test.go @@ -260,6 +260,68 @@ func TestParseInput(t *testing.T) { model.BootVolumeSourceType = utils.Ptr("image") }), }, + { + description: "invalid without image-id, boot-volume-source-id and type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, bootVolumeSourceIdFlag) + delete(flagValues, bootVolumeSourceTypeFlag) + delete(flagValues, imageIdFlag) + }), + isValid: false, + }, + { + description: "invalid with boot-volume-source-id and without type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, bootVolumeSourceTypeFlag) + }), + isValid: false, + }, + { + description: "invalid with boot-volume-source-type is image and without size", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, bootVolumeSizeFlag) + flagValues[bootVolumeSourceIdFlag] = testImageId + flagValues[bootVolumeSourceTypeFlag] = "image" + }), + isValid: false, + }, + { + description: "valid with image-id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, bootVolumeSourceIdFlag) + delete(flagValues, bootVolumeSourceTypeFlag) + delete(flagValues, bootVolumeSizeFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.BootVolumeSourceId = nil + model.BootVolumeSourceType = nil + model.BootVolumeSize = nil + }), + }, + { + description: "valid with boot-volume-source-id and type volume", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, imageIdFlag) + delete(flagValues, bootVolumeSizeFlag) + + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ImageId = nil + model.BootVolumeSize = nil + }), + }, + { + description: "valid with boot-volume-source-id, type volume and size", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, imageIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ImageId = nil + }), + }, } for _, tt := range tests { From 9a22b57f65ebad6659dcdcc3df002ce76254044d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 26 Nov 2024 11:28:39 +0100 Subject: [PATCH 21/21] fix linter issues --- internal/cmd/beta/server/create/create_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cmd/beta/server/create/create_test.go b/internal/cmd/beta/server/create/create_test.go index e4b4d99f3..2ac1afcac 100644 --- a/internal/cmd/beta/server/create/create_test.go +++ b/internal/cmd/beta/server/create/create_test.go @@ -304,7 +304,6 @@ func TestParseInput(t *testing.T) { flagValues: fixtureFlagValues(func(flagValues map[string]string) { delete(flagValues, imageIdFlag) delete(flagValues, bootVolumeSizeFlag) - }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) {