diff --git a/README.md b/README.md index db0ef81a2..b00887fd9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Below you can find a list of the STACKIT services already available in the CLI ( | Service | CLI Commands | Status | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | Observability | `observability` | :white_check_mark: | -| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume`
`beta network-interface`
`beta public-ip`
`beta security-group`
`beta key-pair` | :white_check_mark: (beta) | +| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume`
`beta network-interface`
`beta public-ip`
`beta security-group`
`beta key-pair`
`beta image` | :white_check_mark: (beta)| | Authorization | `project`, `organization` | :white_check_mark: | | DNS | `dns` | :white_check_mark: | | Kubernetes Engine (SKE) | `ske` | :white_check_mark: | diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index d27cf4df6..d5ed983dc 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -41,6 +41,7 @@ stackit beta [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit beta image](./stackit_beta_image.md) - Manage server images * [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs * [stackit beta network](./stackit_beta_network.md) - Provides functionality for networks * [stackit beta network-area](./stackit_beta_network-area.md) - Provides functionality for STACKIT Network Area (SNA) diff --git a/docs/stackit_beta_image.md b/docs/stackit_beta_image.md new file mode 100644 index 000000000..2885055ea --- /dev/null +++ b/docs/stackit_beta_image.md @@ -0,0 +1,38 @@ +## stackit beta image + +Manage server images + +### Synopsis + +Manage the lifecycle of server images. + +``` +stackit beta image [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta image" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta image create](./stackit_beta_image_create.md) - Creates images +* [stackit beta image delete](./stackit_beta_image_delete.md) - Deletes an image +* [stackit beta image describe](./stackit_beta_image_describe.md) - Describes image +* [stackit beta image list](./stackit_beta_image_list.md) - Lists images +* [stackit beta image update](./stackit_beta_image_update.md) - Updates an image + diff --git a/docs/stackit_beta_image_create.md b/docs/stackit_beta_image_create.md new file mode 100644 index 000000000..8044adef0 --- /dev/null +++ b/docs/stackit_beta_image_create.md @@ -0,0 +1,64 @@ +## stackit beta image create + +Creates images + +### Synopsis + +Creates images. + +``` +stackit beta image create [flags] +``` + +### Examples + +``` + Create an image with name 'my-new-image' from a raw disk image located in '/my/raw/image' + $ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image + + Create an image with name 'my-new-image' from a qcow2 image read from '/my/qcow2/image' with labels describing its contents + $ stackit beta image create --name my-new-image --disk-format=qcow2 --local-file-path=/my/qcow2/image --labels os=linux,distro=alpine,version=3.12 +``` + +### Options + +``` + --boot-menu Enables the BIOS bootmenu. + --cdrom-bus string Sets CDROM bus controller type. + --disk-bus string Sets Disk bus controller type. + --disk-format string The disk format of the image. + -h, --help Help for "stackit beta image create" + --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) + --local-file-path string The path to the local disk image file. + --min-disk-size int Size in Gigabyte. + --min-ram int Size in Megabyte. + --name string The name of the image. + --nic-model string Sets virtual nic model. + --no-progress Show no progress indicator for upload. + --os string Enables OS specific optimizations. + --os-distro string Operating System Distribution. + --os-version string Version of the OS. + --protected Protected VM. + --rescue-bus string Sets the device bus when the image is used as a rescue image. + --rescue-device string Sets the device when the image is used as a rescue image. + --secure-boot Enables Secure Boot. + --uefi Enables UEFI boot. + --video-model string Sets Graphic device model. + --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta image](./stackit_beta_image.md) - Manage server images + diff --git a/docs/stackit_beta_image_delete.md b/docs/stackit_beta_image_delete.md new file mode 100644 index 000000000..996596864 --- /dev/null +++ b/docs/stackit_beta_image_delete.md @@ -0,0 +1,40 @@ +## stackit beta image delete + +Deletes an image + +### Synopsis + +Deletes an image by its internal ID. + +``` +stackit beta image delete IMAGE_ID [flags] +``` + +### Examples + +``` + Delete an image with ID "xxx" + $ stackit beta image delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta image delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta image](./stackit_beta_image.md) - Manage server images + diff --git a/docs/stackit_beta_image_describe.md b/docs/stackit_beta_image_describe.md new file mode 100644 index 000000000..6e1059166 --- /dev/null +++ b/docs/stackit_beta_image_describe.md @@ -0,0 +1,40 @@ +## stackit beta image describe + +Describes image + +### Synopsis + +Describes an image by its internal ID. + +``` +stackit beta image describe IMAGE_ID [flags] +``` + +### Examples + +``` + Describe image "xxx" + $ stackit beta image describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta image describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta image](./stackit_beta_image.md) - Manage server images + diff --git a/docs/stackit_beta_image_list.md b/docs/stackit_beta_image_list.md new file mode 100644 index 000000000..19ac93016 --- /dev/null +++ b/docs/stackit_beta_image_list.md @@ -0,0 +1,48 @@ +## stackit beta image list + +Lists images + +### Synopsis + +Lists images by their internal ID. + +``` +stackit beta image list [flags] +``` + +### Examples + +``` + List all images + $ stackit beta image list + + List images with label + $ stackit beta image list --label-selector ARM64,dev + + List the first 10 images + $ stackit beta image list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit beta image list" + --label-selector string Filter by label + --limit int Limit the output to the first n elements +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta image](./stackit_beta_image.md) - Manage server images + diff --git a/docs/stackit_beta_image_update.md b/docs/stackit_beta_image_update.md new file mode 100644 index 000000000..760d561de --- /dev/null +++ b/docs/stackit_beta_image_update.md @@ -0,0 +1,63 @@ +## stackit beta image update + +Updates an image + +### Synopsis + +Updates an image + +``` +stackit beta image update IMAGE_ID [flags] +``` + +### Examples + +``` + Update the name of an image with ID "xxx" + $ stackit beta image update xxx --name my-new-name + + Update the labels of an image with ID "xxx" + $ stackit beta image update xxx --labels label1=value1,label2=value2 +``` + +### Options + +``` + --boot-menu Enables the BIOS bootmenu. + --cdrom-bus string Sets CDROM bus controller type. + --disk-bus string Sets Disk bus controller type. + --disk-format string The disk format of the image. + -h, --help Help for "stackit beta image update" + --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default []) + --local-file-path string The path to the local disk image file. + --min-disk-size int Size in Gigabyte. + --min-ram int Size in Megabyte. + --name string The name of the image. + --nic-model string Sets virtual nic model. + --os string Enables OS specific optimizations. + --os-distro string Operating System Distribution. + --os-version string Version of the OS. + --protected Protected VM. + --rescue-bus string Sets the device bus when the image is used as a rescue image. + --rescue-device string Sets the device when the image is used as a rescue image. + --secure-boot Enables Secure Boot. + --uefi Enables UEFI boot. + --video-model string Sets Graphic device model. + --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta image](./stackit_beta_image.md) - Manage server images + diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md index c6f512ba8..b63225e7e 100644 --- a/docs/stackit_ske_kubeconfig_create.md +++ b/docs/stackit_ske_kubeconfig_create.md @@ -33,14 +33,14 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] Create a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath $ stackit ske kubeconfig create my-cluster --filepath /path/to/config - Get a kubeconfig for the SKE cluster with name "my-cluster" without writing it to a file. - + Get a kubeconfig for the SKE cluster with name "my-cluster" without writing it to a file and format the output as json + $ stackit ske kubeconfig create my-cluster --disable-writing --output-format json ``` ### Options ``` - --disable-writing Disable writing to the kubeconfig file. + --disable-writing Disable the writing of kubeconfig. Set the output format to json or yaml using the --output-format flag to display the kubeconfig. -e, --expiration string Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h --filepath string Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory. -h, --help Help for "stackit ske kubeconfig create" diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 0ec43d0f2..966e5f414 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -3,6 +3,7 @@ package beta import ( "fmt" + image "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image" keypair "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network" networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-area" @@ -52,4 +53,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(publicip.NewCmd(p)) cmd.AddCommand(securitygroup.NewCmd(p)) cmd.AddCommand(keypair.NewCmd(p)) + cmd.AddCommand(image.NewCmd(p)) } diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go new file mode 100644 index 000000000..fee5907c5 --- /dev/null +++ b/internal/cmd/beta/image/create/create.go @@ -0,0 +1,400 @@ +package create + +import ( + "bufio" + "context" + "encoding/json" + goerrors "errors" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + nameFlag = "name" + diskFormatFlag = "disk-format" + localFilePathFlag = "local-file-path" + noProgressIndicatorFlag = "no-progress" + + bootMenuFlag = "boot-menu" + cdromBusFlag = "cdrom-bus" + diskBusFlag = "disk-bus" + nicModelFlag = "nic-model" + operatingSystemFlag = "os" + operatingSystemDistroFlag = "os-distro" + operatingSystemVersionFlag = "os-version" + rescueBusFlag = "rescue-bus" + rescueDeviceFlag = "rescue-device" + secureBootFlag = "secure-boot" + uefiFlag = "uefi" + videoModelFlag = "video-model" + virtioScsiFlag = "virtio-scsi" + + labelsFlag = "labels" + + minDiskSizeFlag = "min-disk-size" + minRamFlag = "min-ram" + protectedFlag = "protected" +) + +type imageConfig struct { + BootMenu *bool + CdromBus *string + DiskBus *string + NicModel *string + OperatingSystem *string + OperatingSystemDistro *string + OperatingSystemVersion *string + RescueBus *string + RescueDevice *string + SecureBoot *bool + Uefi *bool + VideoModel *string + VirtioScsi *bool +} +type inputModel struct { + *globalflags.GlobalFlagModel + + Id *string + Name string + DiskFormat string + LocalFilePath string + Labels *map[string]string + Config *imageConfig + MinDiskSize *int64 + MinRam *int64 + Protected *bool + NoProgressIndicator *bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates images", + Long: "Creates images.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an image with name 'my-new-image' from a raw disk image located in '/my/raw/image'`, + `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image`, + ), + examples.NewExample( + `Create an image with name 'my-new-image' from a qcow2 image read from '/my/qcow2/image' with labels describing its contents`, + `$ stackit beta image create --name my-new-image --disk-format=qcow2 --local-file-path=/my/qcow2/image --labels os=linux,distro=alpine,version=3.12`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) (err 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 + } + + // we open input file first to fail fast, if it is not readable + file, err := os.Open(model.LocalFilePath) + if err != nil { + return fmt.Errorf("create image: file %q is not readable: %w", model.LocalFilePath, err) + } + defer func() { + if inner := file.Close(); inner != nil { + err = fmt.Errorf("error closing input file: %w (%w)", inner, err) + } + }() + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create the image %q?", model.Name) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + result, err := request.Execute() + if err != nil { + return fmt.Errorf("create image: %w", err) + } + model.Id = result.Id + url, ok := result.GetUploadUrlOk() + if !ok { + return fmt.Errorf("create image: no upload URL has been provided") + } + if err := uploadAsync(ctx, p, model, file, *url); err != nil { + return err + } + + if err := outputResult(p, model, result); err != nil { + return err + } + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func uploadAsync(ctx context.Context, p *print.Printer, model *inputModel, file *os.File, url string) error { + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("upload file: %w", err) + } + + var reader io.Reader + if model.NoProgressIndicator != nil && *model.NoProgressIndicator { + reader = file + } else { + var ch <-chan int + reader, ch = newProgressReader(file) + go func() { + ticker := time.NewTicker(2 * time.Second) + var uploaded int + done: + for { + select { + case <-ticker.C: + p.Info("uploaded %3.1f%%\r", 100.0/float64(stat.Size())*float64(uploaded)) + case n, ok := <-ch: + if !ok { + break done + } + if n >= 0 { + uploaded += n + } + } + } + }() + } + + if err = uploadFile(ctx, p, reader, stat.Size(), url); err != nil { + return fmt.Errorf("upload file: %w", err) + } + + return nil +} + +var _ io.Reader = (*progressReader)(nil) + +type progressReader struct { + delegate io.Reader + ch chan int +} + +func newProgressReader(delegate io.Reader) (reader io.Reader, result <-chan int) { + ch := make(chan int) + return &progressReader{ + delegate: delegate, + ch: ch, + }, ch +} + +// Read implements io.Reader. +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.delegate.Read(p) + if goerrors.Is(err, io.EOF) && n <= 0 { + close(pr.ch) + } else { + pr.ch <- n + } + return n, err +} + +func uploadFile(ctx context.Context, p *print.Printer, reader io.Reader, filesize int64, url string) error { + p.Debug(print.DebugLevel, "uploading image to %s", url) + + start := time.Now() + // pass the file contents as stream, as they can get arbitrarily large. We do + // _not_ want to load them into an internal buffer. The downside is, that we + // have to set the content-length header manually + uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bufio.NewReader(reader)) + if err != nil { + return fmt.Errorf("create image: cannot create request: %w", err) + } + uploadRequest.Header.Add("Content-Type", "application/octet-stream") + uploadRequest.ContentLength = filesize + + uploadResponse, err := http.DefaultClient.Do(uploadRequest) + if err != nil { + return fmt.Errorf("create image: error contacting server for upload: %w", err) + } + defer func() { + if inner := uploadResponse.Body.Close(); inner != nil { + err = fmt.Errorf("error closing file: %w (%w)", inner, err) + } + }() + if uploadResponse.StatusCode != http.StatusOK { + return fmt.Errorf("create image: server rejected image upload with %s", uploadResponse.Status) + } + delay := time.Since(start) + p.Debug(print.DebugLevel, "uploaded %d bytes in %v", filesize, delay) + + return nil +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "The name of the image.") + cmd.Flags().String(diskFormatFlag, "", "The disk format of the image. ") + cmd.Flags().String(localFilePathFlag, "", "The path to the local disk image file.") + cmd.Flags().Bool(noProgressIndicatorFlag, false, "Show no progress indicator for upload.") + + cmd.Flags().Bool(bootMenuFlag, false, "Enables the BIOS bootmenu.") + cmd.Flags().String(cdromBusFlag, "", "Sets CDROM bus controller type.") + cmd.Flags().String(diskBusFlag, "", "Sets Disk bus controller type.") + cmd.Flags().String(nicModelFlag, "", "Sets virtual nic model.") + cmd.Flags().String(operatingSystemFlag, "", "Enables OS specific optimizations.") + cmd.Flags().String(operatingSystemDistroFlag, "", "Operating System Distribution.") + cmd.Flags().String(operatingSystemVersionFlag, "", "Version of the OS.") + cmd.Flags().String(rescueBusFlag, "", "Sets the device bus when the image is used as a rescue image.") + cmd.Flags().String(rescueDeviceFlag, "", "Sets the device when the image is used as a rescue image.") + cmd.Flags().Bool(secureBootFlag, false, "Enables Secure Boot.") + cmd.Flags().Bool(uefiFlag, false, "Enables UEFI boot.") + cmd.Flags().String(videoModelFlag, "", "Sets Graphic device model.") + cmd.Flags().Bool(virtioScsiFlag, false, "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.") + + cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'") + + cmd.Flags().Int64(minDiskSizeFlag, 0, "Size in Gigabyte.") + cmd.Flags().Int64(minRamFlag, 0, "Size in Megabyte.") + cmd.Flags().Bool(protectedFlag, false, "Protected VM.") + + if err := flags.MarkFlagsRequired(cmd, nameFlag, diskFormatFlag, localFilePathFlag); err != nil { + cobra.CheckErr(err) + } +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + name := flags.FlagToStringValue(p, cmd, nameFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + DiskFormat: flags.FlagToStringValue(p, cmd, diskFormatFlag), + LocalFilePath: flags.FlagToStringValue(p, cmd, localFilePathFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + NoProgressIndicator: flags.FlagToBoolPointer(p, cmd, noProgressIndicatorFlag), + Config: &imageConfig{ + BootMenu: flags.FlagToBoolPointer(p, cmd, bootMenuFlag), + CdromBus: flags.FlagToStringPointer(p, cmd, cdromBusFlag), + DiskBus: flags.FlagToStringPointer(p, cmd, diskBusFlag), + NicModel: flags.FlagToStringPointer(p, cmd, nicModelFlag), + OperatingSystem: flags.FlagToStringPointer(p, cmd, operatingSystemFlag), + OperatingSystemDistro: flags.FlagToStringPointer(p, cmd, operatingSystemDistroFlag), + OperatingSystemVersion: flags.FlagToStringPointer(p, cmd, operatingSystemVersionFlag), + RescueBus: flags.FlagToStringPointer(p, cmd, rescueBusFlag), + RescueDevice: flags.FlagToStringPointer(p, cmd, rescueDeviceFlag), + SecureBoot: flags.FlagToBoolPointer(p, cmd, secureBootFlag), + Uefi: flags.FlagToBoolPointer(p, cmd, uefiFlag), + VideoModel: flags.FlagToStringPointer(p, cmd, videoModelFlag), + VirtioScsi: flags.FlagToBoolPointer(p, cmd, virtioScsiFlag), + }, + MinDiskSize: flags.FlagToInt64Pointer(p, cmd, minDiskSizeFlag), + MinRam: flags.FlagToInt64Pointer(p, cmd, minRamFlag), + Protected: flags.FlagToBoolPointer(p, cmd, protectedFlag), + } + + 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.ApiCreateImageRequest { + request := apiClient.CreateImage(ctx, model.ProjectId). + CreateImagePayload(createPayload(ctx, model)) + return request +} + +func createPayload(_ context.Context, model *inputModel) iaas.CreateImagePayload { + var labelsMap *map[string]any + 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.CreateImagePayload{ + DiskFormat: &model.DiskFormat, + Name: &model.Name, + Labels: labelsMap, + MinDiskSize: model.MinDiskSize, + MinRam: model.MinRam, + Protected: model.Protected, + } + if model.Config != nil { + payload.Config = &iaas.ImageConfig{ + BootMenu: model.Config.BootMenu, + CdromBus: iaas.NewNullableString(model.Config.CdromBus), + DiskBus: iaas.NewNullableString(model.Config.DiskBus), + NicModel: iaas.NewNullableString(model.Config.NicModel), + OperatingSystem: model.Config.OperatingSystem, + OperatingSystemDistro: iaas.NewNullableString(model.Config.OperatingSystemDistro), + OperatingSystemVersion: iaas.NewNullableString(model.Config.OperatingSystemVersion), + RescueBus: iaas.NewNullableString(model.Config.RescueBus), + RescueDevice: iaas.NewNullableString(model.Config.RescueDevice), + SecureBoot: model.Config.SecureBoot, + Uefi: model.Config.Uefi, + VideoModel: iaas.NewNullableString(model.Config.VideoModel), + VirtioScsi: model.Config.VirtioScsi, + } + } + + return payload +} + +func outputResult(p *print.Printer, model *inputModel, resp *iaas.ImageCreateResponse) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal image: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal image: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Created image %q with id %s\n", model.Name, *model.Id) + return nil + } +} diff --git a/internal/cmd/beta/image/create/create_test.go b/internal/cmd/beta/image/create/create_test.go new file mode 100644 index 000000000..2a2902424 --- /dev/null +++ b/internal/cmd/beta/image/create/create_test.go @@ -0,0 +1,341 @@ +package create + +import ( + "context" + "strconv" + "strings" + "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") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + + testLocalImagePath = "/does/not/exist" + testDiskFormat = "raw" + testDiskSize int64 = 16 * 1024 * 1024 * 1024 + testRamSize int64 = 8 * 1024 * 1024 * 1024 + testName = "test-image" + testProtected = true + testCdRomBus = "test-cdrom" + testDiskBus = "test-diskbus" + testNicModel = "test-nic" + testOperatingSystem = "test-os" + testOperatingSystemDistro = "test-distro" + testOperatingSystemVersion = "test-distro-version" + testRescueBus = "test-rescue-bus" + testRescueDevice = "test-rescue-device" + testBootmenu = true + testSecureBoot = true + testUefi = true + testVideoModel = "test-video-model" + testVirtioScsi = true + testLabels = "foo=FOO,bar=BAR,baz=BAZ" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + + nameFlag: testName, + diskFormatFlag: testDiskFormat, + localFilePathFlag: testLocalImagePath, + bootMenuFlag: strconv.FormatBool(testBootmenu), + cdromBusFlag: testCdRomBus, + diskBusFlag: testDiskBus, + nicModelFlag: testNicModel, + operatingSystemFlag: testOperatingSystem, + operatingSystemDistroFlag: testOperatingSystemDistro, + operatingSystemVersionFlag: testOperatingSystemVersion, + rescueBusFlag: testRescueBus, + rescueDeviceFlag: testRescueDevice, + secureBootFlag: strconv.FormatBool(testSecureBoot), + uefiFlag: strconv.FormatBool(testUefi), + videoModelFlag: testVideoModel, + virtioScsiFlag: strconv.FormatBool(testVirtioScsi), + labelsFlag: testLabels, + minDiskSizeFlag: strconv.Itoa(int(testDiskSize)), + minRamFlag: strconv.Itoa(int(testRamSize)), + protectedFlag: strconv.FormatBool(testProtected), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func parseLabels(labelstring string) map[string]string { + labels := map[string]string{} + for _, part := range strings.Split(labelstring, ",") { + v := strings.Split(part, "=") + labels[v[0]] = v[1] + } + + return labels +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + Name: testName, + DiskFormat: testDiskFormat, + LocalFilePath: testLocalImagePath, + Labels: utils.Ptr(parseLabels(testLabels)), + Config: &imageConfig{ + BootMenu: &testBootmenu, + CdromBus: &testCdRomBus, + DiskBus: &testDiskBus, + NicModel: &testNicModel, + OperatingSystem: &testOperatingSystem, + OperatingSystemDistro: &testOperatingSystemDistro, + OperatingSystemVersion: &testOperatingSystemVersion, + RescueBus: &testRescueBus, + RescueDevice: &testRescueDevice, + SecureBoot: &testSecureBoot, + Uefi: &testUefi, + VideoModel: &testVideoModel, + VirtioScsi: &testVirtioScsi, + }, + MinDiskSize: &testDiskSize, + MinRam: &testRamSize, + Protected: &testProtected, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureCreatePayload(mods ...func(payload *iaas.CreateImagePayload)) (payload iaas.CreateImagePayload) { + payload = iaas.CreateImagePayload{ + Config: &iaas.ImageConfig{ + BootMenu: &testBootmenu, + CdromBus: iaas.NewNullableString(&testCdRomBus), + DiskBus: iaas.NewNullableString(&testDiskBus), + NicModel: iaas.NewNullableString(&testNicModel), + OperatingSystem: &testOperatingSystem, + OperatingSystemDistro: iaas.NewNullableString(&testOperatingSystemDistro), + OperatingSystemVersion: iaas.NewNullableString(&testOperatingSystemVersion), + RescueBus: iaas.NewNullableString(&testRescueBus), + RescueDevice: iaas.NewNullableString(&testRescueDevice), + SecureBoot: &testSecureBoot, + Uefi: &testUefi, + VideoModel: iaas.NewNullableString(&testVideoModel), + VirtioScsi: &testVirtioScsi, + }, + DiskFormat: &testDiskFormat, + Labels: &map[string]interface{}{ + "foo": "FOO", + "bar": "BAR", + "baz": "BAZ", + }, + MinDiskSize: &testDiskSize, + MinRam: &testRamSize, + Name: &testName, + Protected: &testProtected, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateImageRequest)) iaas.ApiCreateImageRequest { + request := testClient.CreateImage(testCtx, testProjectId) + + request = request.CreateImagePayload(fixtureCreatePayload()) + + 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: "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: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "no labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + }, + { + description: "single label", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsFlag] = "foo=bar" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{ + "foo": "bar", + } + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiCreateImageRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no labels", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) { + *request = request.CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { + payload.Labels = nil + })) + }), + }, + { + description: "cd rom bus", + model: fixtureInputModel(func(model *inputModel) { + model.Config.CdromBus = utils.Ptr("foobar") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) { + *request = request.CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { + payload.Config.CdromBus = iaas.NewNullableString(utils.Ptr("foobar")) + })) + }), + }, + { + description: "uefi flag", + model: fixtureInputModel(func(model *inputModel) { + model.Config.Uefi = utils.Ptr(false) + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) { + *request = request.CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { + payload.Config.Uefi = utils.Ptr(false) + })) + }), + }, + } + + 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), + cmp.AllowUnexported(iaas.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/image/delete/delete.go b/internal/cmd/beta/image/delete/delete.go new file mode 100644 index 000000000..3e2b44260 --- /dev/null +++ b/internal/cmd/beta/image/delete/delete.go @@ -0,0 +1,110 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ImageId string +} + +const imageIdArg = "IMAGE_ID" + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", imageIdArg), + Short: "Deletes an image", + Long: "Deletes an image by its internal ID.", + Args: args.SingleArg(imageIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Delete an image with ID "xxx"`, `$ stackit beta image delete xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + imageName, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.ImageId) + if err != nil { + p.Debug(print.ErrorLevel, "get image name: %v", err) + imageName = model.ImageId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the image %q for %q?", imageName, projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + if err := request.Execute(); err != nil { + return fmt.Errorf("delete image: %w", err) + } + p.Info("Deleted image %q for %q\n", imageName, projectLabel) + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ImageId: cliArgs[0], + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteImageRequest { + request := apiClient.DeleteImage(ctx, model.ProjectId, model.ImageId) + return request +} diff --git a/internal/cmd/beta/image/delete/delete_test.go b/internal/cmd/beta/image/delete/delete_test.go new file mode 100644 index 000000000..1fa1ed5bc --- /dev/null +++ b/internal/cmd/beta/image/delete/delete_test.go @@ -0,0 +1,183 @@ +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") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testImageId = uuid.NewString() +) + +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{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + ImageId: testImageId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteImageRequest)) iaas.ApiDeleteImageRequest { + request := testClient.DeleteImage(testCtx, testProjectId, testImageId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + args []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + args: []string{testImageId}, + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: "no arguments", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple arguments", + flagValues: fixtureFlagValues(), + args: []string{"foo", "bar"}, + isValid: false, + }, + { + description: "invalid image id", + flagValues: fixtureFlagValues(), + args: []string{"foo"}, + 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) + } + cmd.SetArgs(tt.args) + + 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) + } + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.args) + 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.ApiDeleteImageRequest + }{ + { + 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/image/describe/describe.go b/internal/cmd/beta/image/describe/describe.go new file mode 100644 index 000000000..3a85f635c --- /dev/null +++ b/internal/cmd/beta/image/describe/describe.go @@ -0,0 +1,173 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ImageId string +} + +const imageIdArg = "IMAGE_ID" + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", imageIdArg), + Short: "Describes image", + Long: "Describes an image by its internal ID.", + Args: args.SingleArg(imageIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Describe image "xxx"`, `$ stackit beta image describe xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + image, err := request.Execute() + if err != nil { + return fmt.Errorf("get image: %w", err) + } + + if err := outputResult(p, model, image); err != nil { + return err + } + + return nil + }, + } + + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetImageRequest { + request := apiClient.GetImage(ctx, model.ProjectId, model.ImageId) + return request +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ImageId: cliArgs[0], + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, model *inputModel, resp *iaas.Image) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal image: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal image: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + if id := resp.Id; id != nil { + table.AddRow("ID", *id) + } + table.AddSeparator() + + if name := resp.Name; name != nil { + table.AddRow("NAME", *name) + table.AddSeparator() + } + if format := resp.DiskFormat; format != nil { + table.AddRow("FORMAT", *format) + table.AddSeparator() + } + if diskSize := resp.MinDiskSize; diskSize != nil { + table.AddRow("DISK SIZE", *diskSize) + table.AddSeparator() + } + if ramSize := resp.MinRam; ramSize != nil { + table.AddRow("RAM SIZE", *ramSize) + table.AddSeparator() + } + if config := resp.Config; config != nil { + if os := config.OperatingSystem; os != nil { + table.AddRow("OPERATING SYSTEM", *os) + table.AddSeparator() + } + if distro := config.OperatingSystemDistro; distro != nil { + table.AddRow("OPERATING SYSTEM DISTRIBUTION", *distro) + table.AddSeparator() + } + if version := config.OperatingSystemVersion; version != nil { + table.AddRow("OPERATING SYSTEM VERSION", *version) + table.AddSeparator() + } + if uefi := config.Uefi; uefi != nil { + table.AddRow("UEFI BOOT", *uefi) + table.AddSeparator() + } + } + + if resp.Labels != nil && len(*resp.Labels) > 0 { + labels := []string{} + for key, value := range *resp.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/image/describe/describe_test.go b/internal/cmd/beta/image/describe/describe_test.go new file mode 100644 index 000000000..4003e78d1 --- /dev/null +++ b/internal/cmd/beta/image/describe/describe_test.go @@ -0,0 +1,194 @@ +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") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testImageId = []string{uuid.NewString()} +) + +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{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + ImageId: testImageId[0], + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetImageRequest)) iaas.ApiGetImageRequest { + request := testClient.GetImage(testCtx, testProjectId, testImageId[0]) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + args []string + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + args: testImageId, + isValid: true, + }, + { + description: "no values", + flagValues: map[string]string{}, + args: testImageId, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + args: testImageId, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + args: testImageId, + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + args: testImageId, + isValid: false, + }, + { + description: "no image id passed", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple image ids passed", + flagValues: fixtureFlagValues(), + args: []string{uuid.NewString(), uuid.NewString()}, + isValid: false, + }, + { + description: "invalid image id passed", + flagValues: fixtureFlagValues(), + args: []string{"foobar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + model, err := parseInput(p, cmd, tt.args) + 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.ApiGetImageRequest + }{ + { + 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/image/image.go b/internal/cmd/beta/image/image.go new file mode 100644 index 000000000..c84ef1430 --- /dev/null +++ b/internal/cmd/beta/image/image.go @@ -0,0 +1,37 @@ +package image + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/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" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "image", + Short: "Manage server images", + Long: "Manage the lifecycle of server images.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand( + create.NewCmd(p), + list.NewCmd(p), + delete.NewCmd(p), + describe.NewCmd(p), + update.NewCmd(p), + ) +} diff --git a/internal/cmd/beta/image/list/list.go b/internal/cmd/beta/image/list/list.go new file mode 100644 index 000000000..1447f75a7 --- /dev/null +++ b/internal/cmd/beta/image/list/list.go @@ -0,0 +1,189 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string + Limit *int64 +} + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists images", + Long: "Lists images by their internal ID.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all images`, + `$ stackit beta image list`, + ), + examples.NewExample( + `List images with label`, + `$ stackit beta image list --label-selector ARM64,dev`, + ), + examples.NewExample( + `List the first 10 images`, + `$ stackit beta image list --limit=10`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list images: %w", err) + } + + if items := response.GetItems(); items == nil || len(*items) == 0 { + p.Info("No images found for project %q", projectLabel) + } else { + if model.Limit != nil && len(*items) > int(*model.Limit) { + *items = (*items)[:*model.Limit] + } + if err := outputResult(p, model.OutputFormat, *items); err != nil { + return fmt.Errorf("output images: %w", err) + } + } + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), + } + + 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.ApiListImagesRequest { + request := apiClient.ListImages(ctx, model.ProjectId) + if model.LabelSelector != nil { + request = request.LabelSelector(*model.LabelSelector) + } + + return request +} +func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal image list: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal image list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "OS", "DISTRIBUTION", "VERSION", "LABELS") + for _, item := range items { + var ( + os string = "n/a" + distro string = "n/a" + version string = "n/a" + ) + if cfg := item.Config; cfg != nil { + if v := cfg.OperatingSystem; v != nil { + os = *v + } + if v := cfg.OperatingSystemDistro; v != nil && v.IsSet() { + distro = *v.Get() + } + if v := cfg.OperatingSystemVersion; v != nil && v.IsSet() { + version = *v.Get() + } + } + table.AddRow(utils.PtrString(item.Id), + utils.PtrString(item.Name), + os, + distro, + version, + utils.JoinStringKeysPtr(*item.Labels, ",")) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/image/list/list_test.go b/internal/cmd/beta/image/list/list_test.go new file mode 100644 index 000000000..70c6112cb --- /dev/null +++ b/internal/cmd/beta/image/list/list_test.go @@ -0,0 +1,212 @@ +package list + +import ( + "context" + "strconv" + "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") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testLabels = "fooKey=fooValue,barKey=barValue,bazKey=bazValue" + testLimit int64 = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + labelSelectorFlag: testLabels, + limitFlag: strconv.Itoa(int(testLimit)), + } + 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}, + LabelSelector: utils.Ptr(testLabels), + Limit: &testLimit, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListImagesRequest)) iaas.ApiListImagesRequest { + request := testClient.ListImages(testCtx, testProjectId) + request = request.LabelSelector(testLabels) + 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: "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: "no labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "single label", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelSelectorFlag] = "foo=bar" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("foo=bar") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListImagesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + + { + description: "no labels", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListImagesRequest) { + *request = request.LabelSelector("") + }), + }, + { + description: "single label", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = utils.Ptr("foo=bar") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListImagesRequest) { + *request = request.LabelSelector("foo=bar") + }), + }, + } + + 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/image/update/update.go b/internal/cmd/beta/image/update/update.go new file mode 100644 index 000000000..9c672e86f --- /dev/null +++ b/internal/cmd/beta/image/update/update.go @@ -0,0 +1,283 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/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" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type imageConfig struct { + BootMenu *bool + CdromBus *string + DiskBus *string + NicModel *string + OperatingSystem *string + OperatingSystemDistro *string + OperatingSystemVersion *string + RescueBus *string + RescueDevice *string + SecureBoot *bool + Uefi *bool + VideoModel *string + VirtioScsi *bool +} + +func (ic *imageConfig) isEmpty() bool { + return ic.BootMenu == nil && + ic.CdromBus == nil && + ic.DiskBus == nil && + ic.NicModel == nil && + ic.OperatingSystem == nil && + ic.OperatingSystemDistro == nil && + ic.OperatingSystemVersion == nil && + ic.RescueBus == nil && + ic.RescueDevice == nil && + ic.SecureBoot == nil && + ic.Uefi == nil && + ic.VideoModel == nil && + ic.VirtioScsi == nil +} + +type inputModel struct { + *globalflags.GlobalFlagModel + + Id string + Name *string + DiskFormat *string + LocalFilePath *string + Labels *map[string]string + Config *imageConfig + MinDiskSize *int64 + MinRam *int64 + Protected *bool +} + +func (im *inputModel) isEmpty() bool { + return im.Name == nil && + im.DiskFormat == nil && + im.LocalFilePath == nil && + im.Labels == nil && + (im.Config == nil || im.Config.isEmpty()) && + im.MinDiskSize == nil && + im.MinRam == nil && + im.Protected == nil +} + +const imageIdArg = "IMAGE_ID" + +const ( + nameFlag = "name" + diskFormatFlag = "disk-format" + localFilePathFlag = "local-file-path" + + bootMenuFlag = "boot-menu" + cdromBusFlag = "cdrom-bus" + diskBusFlag = "disk-bus" + nicModelFlag = "nic-model" + operatingSystemFlag = "os" + operatingSystemDistroFlag = "os-distro" + operatingSystemVersionFlag = "os-version" + rescueBusFlag = "rescue-bus" + rescueDeviceFlag = "rescue-device" + secureBootFlag = "secure-boot" + uefiFlag = "uefi" + videoModelFlag = "video-model" + virtioScsiFlag = "virtio-scsi" + + labelsFlag = "labels" + + minDiskSizeFlag = "min-disk-size" + minRamFlag = "min-ram" + ownerFlag = "owner" + protectedFlag = "protected" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", imageIdArg), + Short: "Updates an image", + Long: "Updates an image", + Args: args.SingleArg(imageIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Update the name of an image with ID "xxx"`, `$ stackit beta image update xxx --name my-new-name`), + examples.NewExample(`Update the labels of an image with ID "xxx"`, `$ stackit beta image update xxx --labels label1=value1,label2=value2`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + imageLabel, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Id) + if err != nil { + p.Debug(print.WarningLevel, "cannot retrieve image name: %v", err) + imageLabel = model.Id + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update the image %q?", imageLabel) + 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 image: %w", err) + } + p.Info("Updated image \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "The name of the image.") + cmd.Flags().String(diskFormatFlag, "", "The disk format of the image. ") + cmd.Flags().String(localFilePathFlag, "", "The path to the local disk image file.") + + cmd.Flags().Bool(bootMenuFlag, false, "Enables the BIOS bootmenu.") + cmd.Flags().String(cdromBusFlag, "", "Sets CDROM bus controller type.") + cmd.Flags().String(diskBusFlag, "", "Sets Disk bus controller type.") + cmd.Flags().String(nicModelFlag, "", "Sets virtual nic model.") + cmd.Flags().String(operatingSystemFlag, "", "Enables OS specific optimizations.") + cmd.Flags().String(operatingSystemDistroFlag, "", "Operating System Distribution.") + cmd.Flags().String(operatingSystemVersionFlag, "", "Version of the OS.") + cmd.Flags().String(rescueBusFlag, "", "Sets the device bus when the image is used as a rescue image.") + cmd.Flags().String(rescueDeviceFlag, "", "Sets the device when the image is used as a rescue image.") + cmd.Flags().Bool(secureBootFlag, false, "Enables Secure Boot.") + cmd.Flags().Bool(uefiFlag, false, "Enables UEFI boot.") + cmd.Flags().String(videoModelFlag, "", "Sets Graphic device model.") + cmd.Flags().Bool(virtioScsiFlag, false, "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.") + + cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'") + + cmd.Flags().Int64(minDiskSizeFlag, 0, "Size in Gigabyte.") + cmd.Flags().Int64(minRamFlag, 0, "Size in Megabyte.") + cmd.Flags().Bool(protectedFlag, false, "Protected VM.") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Id: cliArgs[0], + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + + DiskFormat: flags.FlagToStringPointer(p, cmd, diskFormatFlag), + LocalFilePath: flags.FlagToStringPointer(p, cmd, localFilePathFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + Config: &imageConfig{ + BootMenu: flags.FlagToBoolPointer(p, cmd, bootMenuFlag), + CdromBus: flags.FlagToStringPointer(p, cmd, cdromBusFlag), + DiskBus: flags.FlagToStringPointer(p, cmd, diskBusFlag), + NicModel: flags.FlagToStringPointer(p, cmd, nicModelFlag), + OperatingSystem: flags.FlagToStringPointer(p, cmd, operatingSystemFlag), + OperatingSystemDistro: flags.FlagToStringPointer(p, cmd, operatingSystemDistroFlag), + OperatingSystemVersion: flags.FlagToStringPointer(p, cmd, operatingSystemVersionFlag), + RescueBus: flags.FlagToStringPointer(p, cmd, rescueBusFlag), + RescueDevice: flags.FlagToStringPointer(p, cmd, rescueDeviceFlag), + SecureBoot: flags.FlagToBoolPointer(p, cmd, secureBootFlag), + Uefi: flags.FlagToBoolPointer(p, cmd, uefiFlag), + VideoModel: flags.FlagToStringPointer(p, cmd, videoModelFlag), + VirtioScsi: flags.FlagToBoolPointer(p, cmd, virtioScsiFlag), + }, + MinDiskSize: flags.FlagToInt64Pointer(p, cmd, minDiskSizeFlag), + MinRam: flags.FlagToInt64Pointer(p, cmd, minRamFlag), + Protected: flags.FlagToBoolPointer(p, cmd, protectedFlag), + } + + if model.isEmpty() { + return nil, fmt.Errorf("no flags have been passed") + } + + 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.ApiUpdateImageRequest { + request := apiClient.UpdateImage(ctx, model.ProjectId, model.Id) + payload := iaas.NewUpdateImagePayload() + var labelsMap *map[string]any + 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 + } + } + // Config *ImageConfig `json:"config,omitempty"` + payload.DiskFormat = model.DiskFormat + payload.Labels = labelsMap + payload.MinDiskSize = model.MinDiskSize + payload.MinRam = model.MinRam + payload.Name = model.Name + payload.Protected = model.Protected + + if model.Config != nil { + payload.Config = &iaas.ImageConfig{ + BootMenu: model.Config.BootMenu, + CdromBus: iaas.NewNullableString(model.Config.CdromBus), + DiskBus: iaas.NewNullableString(model.Config.DiskBus), + NicModel: iaas.NewNullableString(model.Config.NicModel), + OperatingSystem: model.Config.OperatingSystem, + OperatingSystemDistro: iaas.NewNullableString(model.Config.OperatingSystemDistro), + OperatingSystemVersion: iaas.NewNullableString(model.Config.OperatingSystemVersion), + RescueBus: iaas.NewNullableString(model.Config.RescueBus), + RescueDevice: iaas.NewNullableString(model.Config.RescueDevice), + SecureBoot: model.Config.SecureBoot, + Uefi: model.Config.Uefi, + VideoModel: iaas.NewNullableString(model.Config.VideoModel), + VirtioScsi: model.Config.VirtioScsi, + } + } + + request = request.UpdateImagePayload(*payload) + + return request +} diff --git a/internal/cmd/beta/image/update/update_test.go b/internal/cmd/beta/image/update/update_test.go new file mode 100644 index 000000000..ebab81e51 --- /dev/null +++ b/internal/cmd/beta/image/update/update_test.go @@ -0,0 +1,383 @@ +package update + +import ( + "context" + "strconv" + "strings" + "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") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + + testImageId = []string{uuid.NewString()} + testLocalImagePath = "/does/not/exist" + testDiskFormat = "raw" + testDiskSize int64 = 16 * 1024 * 1024 * 1024 + testRamSize int64 = 8 * 1024 * 1024 * 1024 + testName = "test-image" + testProtected = true + testCdRomBus = "test-cdrom" + testDiskBus = "test-diskbus" + testNicModel = "test-nic" + testOperatingSystem = "test-os" + testOperatingSystemDistro = "test-distro" + testOperatingSystemVersion = "test-distro-version" + testRescueBus = "test-rescue-bus" + testRescueDevice = "test-rescue-device" + testBootmenu = true + testSecureBoot = true + testUefi = true + testVideoModel = "test-video-model" + testVirtioScsi = true + testLabels = "foo=FOO,bar=BAR,baz=BAZ" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + + nameFlag: testName, + diskFormatFlag: testDiskFormat, + localFilePathFlag: testLocalImagePath, + bootMenuFlag: strconv.FormatBool(testBootmenu), + cdromBusFlag: testCdRomBus, + diskBusFlag: testDiskBus, + nicModelFlag: testNicModel, + operatingSystemFlag: testOperatingSystem, + operatingSystemDistroFlag: testOperatingSystemDistro, + operatingSystemVersionFlag: testOperatingSystemVersion, + rescueBusFlag: testRescueBus, + rescueDeviceFlag: testRescueDevice, + secureBootFlag: strconv.FormatBool(testSecureBoot), + uefiFlag: strconv.FormatBool(testUefi), + videoModelFlag: testVideoModel, + virtioScsiFlag: strconv.FormatBool(testVirtioScsi), + labelsFlag: testLabels, + minDiskSizeFlag: strconv.Itoa(int(testDiskSize)), + minRamFlag: strconv.Itoa(int(testRamSize)), + protectedFlag: strconv.FormatBool(testProtected), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func parseLabels(labelstring string) map[string]string { + labels := map[string]string{} + for _, part := range strings.Split(labelstring, ",") { + v := strings.Split(part, "=") + labels[v[0]] = v[1] + } + + return labels +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + Id: testImageId[0], + Name: &testName, + DiskFormat: &testDiskFormat, + LocalFilePath: &testLocalImagePath, + Labels: utils.Ptr(parseLabels(testLabels)), + Config: &imageConfig{ + BootMenu: &testBootmenu, + CdromBus: &testCdRomBus, + DiskBus: &testDiskBus, + NicModel: &testNicModel, + OperatingSystem: &testOperatingSystem, + OperatingSystemDistro: &testOperatingSystemDistro, + OperatingSystemVersion: &testOperatingSystemVersion, + RescueBus: &testRescueBus, + RescueDevice: &testRescueDevice, + SecureBoot: &testSecureBoot, + Uefi: &testUefi, + VideoModel: &testVideoModel, + VirtioScsi: &testVirtioScsi, + }, + MinDiskSize: &testDiskSize, + MinRam: &testRamSize, + Protected: &testProtected, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureCreatePayload(mods ...func(payload *iaas.UpdateImagePayload)) (payload iaas.UpdateImagePayload) { + payload = iaas.UpdateImagePayload{ + Config: &iaas.ImageConfig{ + BootMenu: &testBootmenu, + CdromBus: iaas.NewNullableString(&testCdRomBus), + DiskBus: iaas.NewNullableString(&testDiskBus), + NicModel: iaas.NewNullableString(&testNicModel), + OperatingSystem: &testOperatingSystem, + OperatingSystemDistro: iaas.NewNullableString(&testOperatingSystemDistro), + OperatingSystemVersion: iaas.NewNullableString(&testOperatingSystemVersion), + RescueBus: iaas.NewNullableString(&testRescueBus), + RescueDevice: iaas.NewNullableString(&testRescueDevice), + SecureBoot: &testSecureBoot, + Uefi: &testUefi, + VideoModel: iaas.NewNullableString(&testVideoModel), + VirtioScsi: &testVirtioScsi, + }, + DiskFormat: &testDiskFormat, + Labels: &map[string]interface{}{ + "foo": "FOO", + "bar": "BAR", + "baz": "BAZ", + }, + MinDiskSize: &testDiskSize, + MinRam: &testRamSize, + Name: &testName, + Protected: &testProtected, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(*iaas.ApiUpdateImageRequest)) iaas.ApiUpdateImageRequest { + request := testClient.UpdateImage(testCtx, testProjectId, testImageId[0]) + + request = request.UpdateImagePayload(fixtureCreatePayload()) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + args []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + args: testImageId, + expectedModel: fixtureInputModel(), + }, + { + description: "no values but valid image id", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + args: testImageId, + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + model.Name = nil + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + args: testImageId, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + args: testImageId, + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + args: testImageId, + isValid: false, + }, + { + description: "no name passed", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + args: testImageId, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + }), + isValid: true, + }, + { + description: "no labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + }), + args: testImageId, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + isValid: true, + }, + { + description: "single label", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsFlag] = "foo=bar" + }), + args: testImageId, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{ + "foo": "bar", + } + }), + }, + { + description: "no image id passed", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "invalid image id passed", + flagValues: fixtureFlagValues(), + args: []string{"foobar"}, + isValid: false, + }, + { + description: "multiple image ids passed", + flagValues: fixtureFlagValues(), + args: []string{uuid.NewString(), uuid.NewString()}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + if err := cmd.Flags().Set(flag, value); err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + model, err := parseInput(p, cmd, tt.args) + 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.ApiUpdateImageRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no labels", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { + *request = request.UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + payload.Labels = nil + })) + }), + }, + { + description: "change name", + model: fixtureInputModel(func(model *inputModel) { + model.Name = utils.Ptr("something else") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { + *request = request.UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + payload.Name = utils.Ptr("something else") + })) + }), + }, + { + description: "change cdrom", + model: fixtureInputModel(func(model *inputModel) { + model.Config.CdromBus = utils.Ptr("something else") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { + *request = request.UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + payload.Config.CdromBus.Set(utils.Ptr("something else")) + })) + }), + }, + } + + 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, iaas.NullableString{}), + 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 e7a455688..c3102cfed 100644 --- a/internal/pkg/services/iaas/utils/utils.go +++ b/internal/pkg/services/iaas/utils/utils.go @@ -17,6 +17,7 @@ type IaaSClient interface { GetNetworkAreaExecute(ctx context.Context, organizationId, areaId string) (*iaas.NetworkArea, error) ListNetworkAreaProjectsExecute(ctx context.Context, organizationId, areaId string) (*iaas.ProjectListResponse, error) GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, networkRangeId string) (*iaas.NetworkRange, error) + GetImageExecute(ctx context.Context, projectId string, imageId string) (*iaas.Image, error) } func GetSecurityGroupRuleName(ctx context.Context, apiClient IaaSClient, projectId, securityGroupRuleId, securityGroupId string) (string, error) { @@ -117,3 +118,14 @@ func GetNetworkRangeFromAPIResponse(prefix string, networkRanges *[]iaas.Network } return iaas.NetworkRange{}, fmt.Errorf("new network range not found in API response") } + +func GetImageName(ctx context.Context, apiClient IaaSClient, projectId, imageId string) (string, error) { + resp, err := apiClient.GetImageExecute(ctx, projectId, imageId) + if err != nil { + return "", fmt.Errorf("get image: %w", err) + } + if resp.Name == nil { + return "", nil + } + return *resp.Name, nil +} diff --git a/internal/pkg/services/iaas/utils/utils_test.go b/internal/pkg/services/iaas/utils/utils_test.go index c7e75a683..01c59aa70 100644 --- a/internal/pkg/services/iaas/utils/utils_test.go +++ b/internal/pkg/services/iaas/utils/utils_test.go @@ -29,6 +29,8 @@ type IaaSClientMocked struct { GetAttachedProjectsResp *iaas.ProjectListResponse GetNetworkAreaRangeFails bool GetNetworkAreaRangeResp *iaas.NetworkRange + GetImageFails bool + GetImageResp *iaas.Image } func (m *IaaSClientMocked) GetSecurityGroupRuleExecute(_ context.Context, _, _, _ string) (*iaas.SecurityGroupRule, error) { @@ -94,6 +96,13 @@ func (m *IaaSClientMocked) GetNetworkAreaRangeExecute(_ context.Context, _, _, _ return m.GetNetworkAreaRangeResp, nil } +func (m *IaaSClientMocked) GetImageExecute(_ context.Context, _, _ string) (*iaas.Image, error) { + if m.GetImageFails { + return nil, fmt.Errorf("could not get image") + } + return m.GetImageResp, nil +} + func TestGetSecurityGroupRuleName(t *testing.T) { type args struct { getInstanceFails bool @@ -662,3 +671,47 @@ func TestGetNetworkRangeFromAPIResponse(t *testing.T) { }) } } + +func TestGetImageName(t *testing.T) { + tests := []struct { + name string + imageResp *iaas.Image + imageErr bool + want string + wantErr bool + }{ + { + name: "successful retrieval", + imageResp: &iaas.Image{Name: utils.Ptr("test-image")}, + want: "test-image", + wantErr: false, + }, + { + name: "error on retrieval", + imageErr: true, + wantErr: true, + }, + { + name: "nil name", + imageErr: false, + imageResp: &iaas.Image{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &IaaSClientMocked{ + GetImageFails: tt.imageErr, + GetImageResp: tt.imageResp, + } + got, err := GetImageName(context.Background(), client, "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetImageName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetImageName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/utils/strings.go b/internal/pkg/utils/strings.go new file mode 100644 index 000000000..6c33cfc82 --- /dev/null +++ b/internal/pkg/utils/strings.go @@ -0,0 +1,26 @@ +package utils + +import ( + "strings" +) + +// JoinStringKeys concatenates the string keys of a map, each separatore by the +// [sep] string. +func JoinStringKeys(m map[string]any, sep string) string { + keys := make([]string, len(m)) + i := 0 + for k := range m { + keys[i] = k + i++ + } + return strings.Join(keys, sep) +} + +// JoinStringKeysPtr concatenates the string keys of a map pointer, each separatore by the +// [sep] string. +func JoinStringKeysPtr(m map[string]any, sep string) string { + if m == nil { + return "" + } + return JoinStringKeys(m, sep) +}