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)
+}