From 6106f22f1b6e1748411937411033719958c8deda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Fri, 10 Jan 2025 14:39:34 +0100
Subject: [PATCH 01/15] feat: implementation of list command
---
internal/cmd/beta/beta.go | 2 +
internal/cmd/beta/image/image.go | 30 +++
internal/cmd/beta/image/image/list.go | 173 +++++++++++++++++
internal/cmd/beta/image/image/list_test.go | 208 +++++++++++++++++++++
internal/pkg/utils/strings.go | 26 +++
5 files changed, 439 insertions(+)
create mode 100644 internal/cmd/beta/image/image.go
create mode 100644 internal/cmd/beta/image/image/list.go
create mode 100644 internal/cmd/beta/image/image/list_test.go
create mode 100644 internal/pkg/utils/strings.go
diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go
index 0ec43d0f2..41d00dd1d 100644
--- a/internal/cmd/beta/beta.go
+++ b/internal/cmd/beta/beta.go
@@ -9,6 +9,7 @@ import (
networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-interface"
publicip "github.com/stackitcloud/stackit-cli/internal/cmd/beta/public-ip"
securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group"
+ image "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume"
@@ -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/image.go b/internal/cmd/beta/image/image.go
new file mode 100644
index 000000000..6926dda0b
--- /dev/null
+++ b/internal/cmd/beta/image/image.go
@@ -0,0 +1,30 @@
+package security_group
+
+import (
+ list "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/image"
+ "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),
+ )
+}
diff --git a/internal/cmd/beta/image/image/list.go b/internal/cmd/beta/image/image/list.go
new file mode 100644
index 000000000..9bbe0b590
--- /dev/null
+++ b/internal/cmd/beta/image/image/list.go
@@ -0,0 +1,173 @@
+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
+}
+
+const (
+ labelSelectorFlag = "label-selector"
+)
+
+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`),
+ ),
+ 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 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")
+}
+
+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),
+ }
+
+ 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/image/list_test.go b/internal/cmd/beta/image/image/list_test.go
new file mode 100644
index 000000000..ea6c500d3
--- /dev/null
+++ b/internal/cmd/beta/image/image/list_test.go
@@ -0,0 +1,208 @@
+package list
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+ testLabels = "fooKey=fooValue,barKey=barValue,bazKey=bazValue"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ labelSelectorFlag: testLabels,
+ }
+ 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),
+ }
+ 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/pkg/utils/strings.go b/internal/pkg/utils/strings.go
new file mode 100644
index 000000000..db00d9a13
--- /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)
+}
From 7b1afb29a3a976da14ca0e6e8b236e062768a958 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Fri, 10 Jan 2025 18:12:30 +0100
Subject: [PATCH 02/15] feature: create command
---
internal/cmd/beta/image/create/create.go | 321 ++++++++++++++++
internal/cmd/beta/image/create/create_test.go | 353 ++++++++++++++++++
internal/cmd/beta/image/image.go | 3 +-
3 files changed, 676 insertions(+), 1 deletion(-)
create mode 100644 internal/cmd/beta/image/create/create.go
create mode 100644 internal/cmd/beta/image/create/create_test.go
diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go
new file mode 100644
index 000000000..c573c6215
--- /dev/null
+++ b/internal/cmd/beta/image/create/create.go
@@ -0,0 +1,321 @@
+package create
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "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"
+
+ 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"
+)
+
+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
+
+ Name string
+ DiskFormat string
+ LocalFilePath string
+ Labels *map[string]string
+ Config *imageConfig
+ MinDiskSize *int64
+ MinRam *int64
+ Owner *string
+ Protected *bool
+ Scope *string
+ Status *string
+}
+
+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 a named imaged`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image`),
+ examples.NewExample(`Create a named image with labels`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image--labels dev,amd64`),
+ ),
+ 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
+ }
+
+ // 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 file.Close()
+
+ 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)
+ }
+ url, ok := result.GetUploadUrlOk()
+ if !ok {
+ return fmt.Errorf("create image: no upload URL has been provided")
+ }
+ if err := uploadFile(ctx, p, file, *url); err != nil {
+ return err
+ }
+
+ if err := outputResult(p, model, result); err != nil {
+ return err
+ }
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) error {
+ var filesize int64
+ if stat, err := file.Stat(); err != nil {
+ p.Debug(print.DebugLevel, "create image: cannot open file %q: %w", file.Name(), err)
+ } else {
+ filesize = stat.Size()
+ }
+ 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(file))
+ 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)
+ }
+ 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(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),
+ 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(ctx 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\n", model.Name)
+ 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..672fd369c
--- /dev/null
+++ b/internal/cmd/beta/image/create/create_test.go
@@ -0,0 +1,353 @@
+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: 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 toStringAnyMapPtr(m map[string]string) map[string]any {
+ if m == nil {
+ return nil
+ }
+ result := map[string]any{}
+ for k, v := range m {
+ result[k] = v
+ }
+ return result
+}
+
+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
+}
+
+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/image.go b/internal/cmd/beta/image/image.go
index 6926dda0b..a3b7c680a 100644
--- a/internal/cmd/beta/image/image.go
+++ b/internal/cmd/beta/image/image.go
@@ -1,6 +1,7 @@
package security_group
import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/create"
list "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/image"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
@@ -24,7 +25,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
func addSubcommands(cmd *cobra.Command, p *print.Printer) {
cmd.AddCommand(
- // create.NewCmd(p),
+ create.NewCmd(p),
list.NewCmd(p),
)
}
From 025882919bc6233d99d93fef3e30c919da624756 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 09:49:58 +0100
Subject: [PATCH 03/15] feat: corrected package name
---
internal/cmd/beta/image/{image => list}/list.go | 0
internal/cmd/beta/image/{image => list}/list_test.go | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename internal/cmd/beta/image/{image => list}/list.go (100%)
rename internal/cmd/beta/image/{image => list}/list_test.go (100%)
diff --git a/internal/cmd/beta/image/image/list.go b/internal/cmd/beta/image/list/list.go
similarity index 100%
rename from internal/cmd/beta/image/image/list.go
rename to internal/cmd/beta/image/list/list.go
diff --git a/internal/cmd/beta/image/image/list_test.go b/internal/cmd/beta/image/list/list_test.go
similarity index 100%
rename from internal/cmd/beta/image/image/list_test.go
rename to internal/cmd/beta/image/list/list_test.go
From a742d27a1dc4303132e3d56e8946f2c4f969c93a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 09:50:12 +0100
Subject: [PATCH 04/15] feat: implemented delete command
---
internal/cmd/beta/image/delete/delete.go | 110 +++++++++++
internal/cmd/beta/image/delete/delete_test.go | 183 ++++++++++++++++++
internal/cmd/beta/image/image.go | 4 +-
internal/pkg/services/iaas/utils/utils.go | 12 ++
.../pkg/services/iaas/utils/utils_test.go | 53 +++++
5 files changed, 361 insertions(+), 1 deletion(-)
create mode 100644 internal/cmd/beta/image/delete/delete.go
create mode 100644 internal/cmd/beta/image/delete/delete_test.go
diff --git a/internal/cmd/beta/image/delete/delete.go b/internal/cmd/beta/image/delete/delete.go
new file mode 100644
index 000000000..a839df04a
--- /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.Warn("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/image.go b/internal/cmd/beta/image/image.go
index a3b7c680a..6d58084c7 100644
--- a/internal/cmd/beta/image/image.go
+++ b/internal/cmd/beta/image/image.go
@@ -2,7 +2,8 @@ package security_group
import (
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/create"
- list "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/image"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/delete"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
@@ -27,5 +28,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
cmd.AddCommand(
create.NewCmd(p),
list.NewCmd(p),
+ delete.NewCmd(p),
)
}
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)
+ }
+ })
+ }
+}
From 35712d7a7c88fed50e611c7d2d5023840a151963 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 10:04:28 +0100
Subject: [PATCH 05/15] feat: add describe command
---
internal/cmd/beta/image/describe/describe.go | 173 ++++++++++++++++
.../cmd/beta/image/describe/describe_test.go | 194 ++++++++++++++++++
internal/cmd/beta/image/image.go | 4 +-
3 files changed, 370 insertions(+), 1 deletion(-)
create mode 100644 internal/cmd/beta/image/describe/describe.go
create mode 100644 internal/cmd/beta/image/describe/describe_test.go
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..53a24ef52
--- /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
index 6d58084c7..a9f8bbedb 100644
--- a/internal/cmd/beta/image/image.go
+++ b/internal/cmd/beta/image/image.go
@@ -2,8 +2,9 @@ package security_group
import (
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/create"
- "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image/list"
"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/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
@@ -29,5 +30,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
create.NewCmd(p),
list.NewCmd(p),
delete.NewCmd(p),
+ describe.NewCmd(p),
)
}
From 9aaa6258490dba577ec7584eb002f712484f2e71 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 13:10:49 +0100
Subject: [PATCH 06/15] feat: added update command
---
internal/cmd/beta/image/image.go | 4 +-
internal/cmd/beta/image/update/update.go | 283 +++++++++++++
internal/cmd/beta/image/update/update_test.go | 383 ++++++++++++++++++
3 files changed, 669 insertions(+), 1 deletion(-)
create mode 100644 internal/cmd/beta/image/update/update.go
create mode 100644 internal/cmd/beta/image/update/update_test.go
diff --git a/internal/cmd/beta/image/image.go b/internal/cmd/beta/image/image.go
index a9f8bbedb..c84ef1430 100644
--- a/internal/cmd/beta/image/image.go
+++ b/internal/cmd/beta/image/image.go
@@ -1,10 +1,11 @@
-package security_group
+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"
@@ -31,5 +32,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
list.NewCmd(p),
delete.NewCmd(p),
describe.NewCmd(p),
+ update.NewCmd(p),
)
}
diff --git a/internal/cmd/beta/image/update/update.go b/internal/cmd/beta/image/update/update.go
new file mode 100644
index 000000000..a4f315812
--- /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 image "xxx"`, `$ stackit beta image update xxx --name my-new-name`),
+ examples.NewExample(`Update the labels of image "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.Warn("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)
+ }
+ })
+ }
+}
From 4e59a5e9324a3cedbc67e8eba2a7ec43441e3b63 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 13:10:55 +0100
Subject: [PATCH 07/15] feat: linter fixes
---
internal/cmd/beta/image/create/create.go | 24 ++++++++++++-------
internal/cmd/beta/image/create/create_test.go | 20 ++++------------
internal/cmd/beta/image/delete/delete.go | 2 +-
.../cmd/beta/image/describe/describe_test.go | 10 ++++----
internal/cmd/beta/image/list/list.go | 1 -
5 files changed, 26 insertions(+), 31 deletions(-)
diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go
index c573c6215..e4f6b2a59 100644
--- a/internal/cmd/beta/image/create/create.go
+++ b/internal/cmd/beta/image/create/create.go
@@ -67,6 +67,7 @@ type imageConfig struct {
type inputModel struct {
*globalflags.GlobalFlagModel
+ Id *string
Name string
DiskFormat string
LocalFilePath string
@@ -74,10 +75,7 @@ type inputModel struct {
Config *imageConfig
MinDiskSize *int64
MinRam *int64
- Owner *string
Protected *bool
- Scope *string
- Status *string
}
func NewCmd(p *print.Printer) *cobra.Command {
@@ -90,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
examples.NewExample(`Create a named imaged`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image`),
examples.NewExample(`Create a named image with labels`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image--labels dev,amd64`),
),
- RunE: func(cmd *cobra.Command, _ []string) error {
+ RunE: func(cmd *cobra.Command, _ []string) (err error) {
ctx := context.Background()
model, err := parseInput(p, cmd)
if err != nil {
@@ -108,7 +106,11 @@ func NewCmd(p *print.Printer) *cobra.Command {
if err != nil {
return fmt.Errorf("create image: file %q is not readable: %w", model.LocalFilePath, err)
}
- defer file.Close()
+ 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)
@@ -125,6 +127,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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")
@@ -145,7 +148,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return cmd
}
-func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) error {
+func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) (err error) {
var filesize int64
if stat, err := file.Stat(); err != nil {
p.Debug(print.DebugLevel, "create image: cannot open file %q: %w", file.Name(), err)
@@ -168,6 +171,11 @@ func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string
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: %wqq (%w)", inner, err)
+ }
+ }()
if uploadResponse.StatusCode != http.StatusOK {
return fmt.Errorf("create image: server rejected image upload with %s", uploadResponse.Status)
}
@@ -258,7 +266,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli
return request
}
-func createPayload(ctx context.Context, model *inputModel) iaas.CreateImagePayload {
+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{}
@@ -315,7 +323,7 @@ func outputResult(p *print.Printer, model *inputModel, resp *iaas.ImageCreateRes
return nil
default:
- p.Outputf("Created image %q\n", model.Name)
+ 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
index 672fd369c..2a2902424 100644
--- a/internal/cmd/beta/image/create/create_test.go
+++ b/internal/cmd/beta/image/create/create_test.go
@@ -78,14 +78,14 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
return flagValues
}
-func parseLabels(labelstring string) *map[string]string {
+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
+ return labels
}
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
@@ -94,7 +94,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
Name: testName,
DiskFormat: testDiskFormat,
LocalFilePath: testLocalImagePath,
- Labels: parseLabels(testLabels),
+ Labels: utils.Ptr(parseLabels(testLabels)),
Config: &imageConfig{
BootMenu: &testBootmenu,
CdromBus: &testCdRomBus,
@@ -120,17 +120,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func toStringAnyMapPtr(m map[string]string) map[string]any {
- if m == nil {
- return nil
- }
- result := map[string]any{}
- for k, v := range m {
- result[k] = v
- }
- return result
-}
-
func fixtureCreatePayload(mods ...func(payload *iaas.CreateImagePayload)) (payload iaas.CreateImagePayload) {
payload = iaas.CreateImagePayload{
Config: &iaas.ImageConfig{
@@ -162,7 +151,7 @@ func fixtureCreatePayload(mods ...func(payload *iaas.CreateImagePayload)) (paylo
for _, mod := range mods {
mod(&payload)
}
- return
+ return payload
}
func fixtureRequest(mods ...func(request *iaas.ApiCreateImageRequest)) iaas.ApiCreateImageRequest {
@@ -347,7 +336,6 @@ func TestBuildRequest(t *testing.T) {
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
index a839df04a..df3411ad3 100644
--- a/internal/cmd/beta/image/delete/delete.go
+++ b/internal/cmd/beta/image/delete/delete.go
@@ -1,4 +1,4 @@
- package delete
+package delete
import (
"context"
diff --git a/internal/cmd/beta/image/describe/describe_test.go b/internal/cmd/beta/image/describe/describe_test.go
index 53a24ef52..4003e78d1 100644
--- a/internal/cmd/beta/image/describe/describe_test.go
+++ b/internal/cmd/beta/image/describe/describe_test.go
@@ -18,10 +18,10 @@ 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()}
+ 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 {
@@ -37,7 +37,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault},
- ImageId: testImageId[0],
+ ImageId: testImageId[0],
}
for _, mod := range mods {
mod(model)
diff --git a/internal/cmd/beta/image/list/list.go b/internal/cmd/beta/image/list/list.go
index 9bbe0b590..63ed61c1d 100644
--- a/internal/cmd/beta/image/list/list.go
+++ b/internal/cmd/beta/image/list/list.go
@@ -150,7 +150,6 @@ func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) err
}
if v := cfg.OperatingSystemDistro; v != nil && v.IsSet() {
distro = *v.Get()
-
}
if v := cfg.OperatingSystemVersion; v != nil && v.IsSet() {
version = *v.Get()
From 2e0904609823787b977d1e3d4f5b6b8060e71fae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 13:12:58 +0100
Subject: [PATCH 08/15] feat: documentation
---
README.md | 2 +-
docs/stackit_beta.md | 1 +
docs/stackit_beta_image.md | 38 +++++++++++++++++
docs/stackit_beta_image_create.md | 63 +++++++++++++++++++++++++++++
docs/stackit_beta_image_delete.md | 40 ++++++++++++++++++
docs/stackit_beta_image_describe.md | 40 ++++++++++++++++++
docs/stackit_beta_image_list.md | 44 ++++++++++++++++++++
docs/stackit_beta_image_update.md | 63 +++++++++++++++++++++++++++++
8 files changed, 290 insertions(+), 1 deletion(-)
create mode 100644 docs/stackit_beta_image.md
create mode 100644 docs/stackit_beta_image_create.md
create mode 100644 docs/stackit_beta_image_delete.md
create mode 100644 docs/stackit_beta_image_describe.md
create mode 100644 docs/stackit_beta_image_list.md
create mode 100644 docs/stackit_beta_image_update.md
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..5d3fd4b7c
--- /dev/null
+++ b/docs/stackit_beta_image_create.md
@@ -0,0 +1,63 @@
+## stackit beta image create
+
+Creates images
+
+### Synopsis
+
+Creates images.
+
+```
+stackit beta image create [flags]
+```
+
+### Examples
+
+```
+ Create a named imaged
+ $ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image
+
+ Create a named image with labels
+ $ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image--labels dev,amd64
+```
+
+### 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.
+ --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..253e922fa
--- /dev/null
+++ b/docs/stackit_beta_image_list.md
@@ -0,0 +1,44 @@
+## 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
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta image list"
+ --label-selector string Filter by label
+```
+
+### 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..63097aa5d
--- /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 image "xxx"
+ $ stackit beta image update xxx --name my-new-name
+
+ Update the labels of image "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
+
From 0da36dd72941447a83e3838d0c4389a07840ed74 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
Date: Tue, 14 Jan 2025 17:45:02 +0100
Subject: [PATCH 09/15] Update internal/cmd/beta/image/update/update.go
Co-authored-by: Marcel Jacek <72880145+marceljk@users.noreply.github.com>
---
internal/cmd/beta/image/update/update.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/cmd/beta/image/update/update.go b/internal/cmd/beta/image/update/update.go
index a4f315812..3dc6ed1d2 100644
--- a/internal/cmd/beta/image/update/update.go
+++ b/internal/cmd/beta/image/update/update.go
@@ -111,7 +111,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
Long: "Updates an image",
Args: args.SingleArg(imageIdArg, utils.ValidateUUID),
Example: examples.Build(
- examples.NewExample(`Update the name of image "xxx"`, `$ stackit beta image update xxx --name my-new-name`),
+ 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 image "xxx"`, `$ stackit beta image update xxx --labels label1=value1,label2=value2`),
),
RunE: func(cmd *cobra.Command, args []string) error {
From 90e121b8ae02ea2abb59f96538b8370dba3190f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Tue, 14 Jan 2025 18:03:22 +0100
Subject: [PATCH 10/15] feat: fix review findings
---
internal/cmd/beta/image/create/create.go | 98 +++++++++++++++--------
internal/cmd/beta/image/list/list.go | 21 ++++-
internal/cmd/beta/image/list/list_test.go | 12 ++-
3 files changed, 93 insertions(+), 38 deletions(-)
diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go
index e4f6b2a59..4a7d7fda8 100644
--- a/internal/cmd/beta/image/create/create.go
+++ b/internal/cmd/beta/image/create/create.go
@@ -85,8 +85,14 @@ func NewCmd(p *print.Printer) *cobra.Command {
Long: "Creates images.",
Args: args.NoArgs,
Example: examples.Build(
- examples.NewExample(`Create a named imaged`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image`),
- examples.NewExample(`Create a named image with labels`, `$ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image--labels dev,amd64`),
+ examples.NewExample(
+ `Create a named image '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 a named image '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()
@@ -132,7 +138,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
if !ok {
return fmt.Errorf("create image: no upload URL has been provided")
}
- if err := uploadFile(ctx, p, file, *url); err != nil {
+ if err := uploadAsync(ctx, p, file, *url); err != nil {
return err
}
@@ -148,41 +154,69 @@ func NewCmd(p *print.Printer) *cobra.Command {
return cmd
}
-func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) (err error) {
- var filesize int64
- if stat, err := file.Stat(); err != nil {
- p.Debug(print.DebugLevel, "create image: cannot open file %q: %w", file.Name(), err)
- } else {
- filesize = stat.Size()
- }
- p.Debug(print.DebugLevel, "uploading image to %s", url)
+func uploadAsync(ctx context.Context, p *print.Printer, file *os.File, url string) error {
+ ticker := time.NewTicker(5 * time.Second)
+ ch := uploadFile(ctx, p, file, 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(file))
- if err != nil {
- return fmt.Errorf("create image: cannot create request: %w", err)
+ for {
+ select {
+ case <-ticker.C:
+ p.Info("uploading for %s\n", time.Since(start))
+ case err := <-ch:
+ return 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: %wqq (%w)", inner, err)
+func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) chan error {
+ ch := make(chan error)
+ go func() {
+ defer close(ch)
+ var filesize int64
+ if stat, err := file.Stat(); err != nil {
+ ch <- fmt.Errorf("create image: cannot read file size %q: %w", file.Name(), err)
+ return
+ } else {
+ filesize = stat.Size()
+ }
+ 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(file))
+ if err != nil {
+ ch <- fmt.Errorf("create image: cannot create request: %w", err)
+ return
}
+ uploadRequest.Header.Add("Content-Type", "application/octet-stream")
+ uploadRequest.ContentLength = filesize
+
+ uploadResponse, err := http.DefaultClient.Do(uploadRequest)
+ if err != nil {
+ ch <- fmt.Errorf("create image: error contacting server for upload: %w", err)
+ return
+ }
+ defer func() {
+ if inner := uploadResponse.Body.Close(); inner != nil {
+ err = fmt.Errorf("error closing file: %w (%w)", inner, err)
+ }
+ }()
+ if uploadResponse.StatusCode != http.StatusOK {
+ ch <- fmt.Errorf("create image: server rejected image upload with %s", uploadResponse.Status)
+ return
+ }
+ delay := time.Since(start)
+ p.Debug(print.DebugLevel, "uploaded %d bytes in %v", filesize, delay)
+
+ ch <- nil
+ return
+
}()
- 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
+ return ch
}
func configureFlags(cmd *cobra.Command) {
diff --git a/internal/cmd/beta/image/list/list.go b/internal/cmd/beta/image/list/list.go
index 63ed61c1d..7769d7059 100644
--- a/internal/cmd/beta/image/list/list.go
+++ b/internal/cmd/beta/image/list/list.go
@@ -23,10 +23,12 @@ import (
type inputModel struct {
*globalflags.GlobalFlagModel
LabelSelector *string
+ Limit *int64
}
const (
labelSelectorFlag = "label-selector"
+ limitFlag = "limit"
)
func NewCmd(p *print.Printer) *cobra.Command {
@@ -36,8 +38,18 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 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()
@@ -69,6 +81,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
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)
}
@@ -84,6 +99,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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) {
@@ -95,6 +111,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
model := inputModel{
GlobalFlagModel: globalFlags,
LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag),
}
if p.IsVerbosityDebug() {
diff --git a/internal/cmd/beta/image/list/list_test.go b/internal/cmd/beta/image/list/list_test.go
index ea6c500d3..70c6112cb 100644
--- a/internal/cmd/beta/image/list/list_test.go
+++ b/internal/cmd/beta/image/list/list_test.go
@@ -2,6 +2,7 @@ package list
import (
"context"
+ "strconv"
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
@@ -19,16 +20,18 @@ 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"
+ 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)
@@ -40,6 +43,7 @@ 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)
From 09bd3af51e14dc7cf31bf08d6148c1b42cbc0a59 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Wed, 15 Jan 2025 07:44:19 +0100
Subject: [PATCH 11/15] feat: add progress indicator based on actually uploaded
data
---
internal/cmd/beta/image/create/create.go | 176 ++++++++++++++---------
1 file changed, 105 insertions(+), 71 deletions(-)
diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go
index 4a7d7fda8..354d97332 100644
--- a/internal/cmd/beta/image/create/create.go
+++ b/internal/cmd/beta/image/create/create.go
@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"encoding/json"
+ goerrors "errors"
"fmt"
+ "io"
"net/http"
"os"
"time"
@@ -23,9 +25,10 @@ import (
)
const (
- nameFlag = "name"
- diskFormatFlag = "disk-format"
- localFilePathFlag = "local-file-path"
+ nameFlag = "name"
+ diskFormatFlag = "disk-format"
+ localFilePathFlag = "local-file-path"
+ noProgressIndicatorFlag = "no-progress"
bootMenuFlag = "boot-menu"
cdromBusFlag = "cdrom-bus"
@@ -67,15 +70,16 @@ type imageConfig struct {
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
+ 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 {
@@ -138,7 +142,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
if !ok {
return fmt.Errorf("create image: no upload URL has been provided")
}
- if err := uploadAsync(ctx, p, file, *url); err != nil {
+ if err := uploadAsync(ctx, p, model, file, *url); err != nil {
return err
}
@@ -154,75 +158,104 @@ func NewCmd(p *print.Printer) *cobra.Command {
return cmd
}
-func uploadAsync(ctx context.Context, p *print.Printer, file *os.File, url string) error {
- ticker := time.NewTicker(5 * time.Second)
- ch := uploadFile(ctx, p, file, url)
+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)
+ }
- start := time.Now()
- for {
- select {
- case <-ticker.C:
- p.Info("uploading for %s\n", time.Since(start))
- case err := <-ch:
- return 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
+ for {
+ select {
+ case <-ticker.C:
+ p.Info("uploaded %3.1f%%\n", 100.0/float64(stat.Size())*float64(uploaded))
+ case n := <-ch:
+ 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
}
-func uploadFile(ctx context.Context, p *print.Printer, file *os.File, url string) chan error {
- ch := make(chan error)
- go func() {
- defer close(ch)
- var filesize int64
- if stat, err := file.Stat(); err != nil {
- ch <- fmt.Errorf("create image: cannot read file size %q: %w", file.Name(), err)
- return
- } else {
- filesize = stat.Size()
- }
- p.Debug(print.DebugLevel, "uploading image to %s", url)
+var _ io.Reader = (*progressReader)(nil)
- 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(file))
- if err != nil {
- ch <- fmt.Errorf("create image: cannot create request: %w", err)
- return
- }
- uploadRequest.Header.Add("Content-Type", "application/octet-stream")
- uploadRequest.ContentLength = filesize
+type progressReader struct {
+ delegate io.Reader
+ ch chan int
+}
- uploadResponse, err := http.DefaultClient.Do(uploadRequest)
- if err != nil {
- ch <- fmt.Errorf("create image: error contacting server for upload: %w", err)
- return
- }
- defer func() {
- if inner := uploadResponse.Body.Close(); inner != nil {
- err = fmt.Errorf("error closing file: %w (%w)", inner, err)
- }
- }()
- if uploadResponse.StatusCode != http.StatusOK {
- ch <- fmt.Errorf("create image: server rejected image upload with %s", uploadResponse.Status)
- return
- }
- delay := time.Since(start)
- p.Debug(print.DebugLevel, "uploaded %d bytes in %v", filesize, delay)
+func newProgressReader(delegate io.Reader) (io.Reader, <-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)
- ch <- nil
- return
+ 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 ch
+ 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.")
@@ -257,11 +290,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
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),
+ 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),
From 178de3717b3b82957ca33fe22df3380e50bdd1f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Wed, 15 Jan 2025 09:03:21 +0100
Subject: [PATCH 12/15] feat: added explicit termination of progress indicator
---
internal/cmd/beta/image/create/create.go | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go
index 354d97332..8a6a39ef4 100644
--- a/internal/cmd/beta/image/create/create.go
+++ b/internal/cmd/beta/image/create/create.go
@@ -173,11 +173,15 @@ func uploadAsync(ctx context.Context, p *print.Printer, model *inputModel, file
go func() {
ticker := time.NewTicker(2 * time.Second)
var uploaded int
+ done:
for {
select {
case <-ticker.C:
p.Info("uploaded %3.1f%%\n", 100.0/float64(stat.Size())*float64(uploaded))
- case n := <-ch:
+ case n,ok := <-ch:
+ if !ok {
+ break done
+ }
if n >= 0 {
uploaded += n
}
From 40591fd6131d983fda44cdd2b085bcb65a87eec2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Wed, 15 Jan 2025 10:01:34 +0100
Subject: [PATCH 13/15] chore: fix review findings
---
internal/cmd/beta/image/create/create.go | 15 +++++++--------
internal/cmd/beta/image/delete/delete.go | 2 +-
internal/cmd/beta/image/update/update.go | 4 ++--
3 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/internal/cmd/beta/image/create/create.go b/internal/cmd/beta/image/create/create.go
index 8a6a39ef4..fee5907c5 100644
--- a/internal/cmd/beta/image/create/create.go
+++ b/internal/cmd/beta/image/create/create.go
@@ -48,7 +48,6 @@ const (
minDiskSizeFlag = "min-disk-size"
minRamFlag = "min-ram"
- ownerFlag = "owner"
protectedFlag = "protected"
)
@@ -90,12 +89,12 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
- `Create a named image 'my-new-image' from a raw disk image located in '/my/raw/image'`,
+ `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 a named image '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`,
+ `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) {
@@ -173,12 +172,12 @@ func uploadAsync(ctx context.Context, p *print.Printer, model *inputModel, file
go func() {
ticker := time.NewTicker(2 * time.Second)
var uploaded int
- done:
+ done:
for {
select {
case <-ticker.C:
- p.Info("uploaded %3.1f%%\n", 100.0/float64(stat.Size())*float64(uploaded))
- case n,ok := <-ch:
+ p.Info("uploaded %3.1f%%\r", 100.0/float64(stat.Size())*float64(uploaded))
+ case n, ok := <-ch:
if !ok {
break done
}
@@ -204,7 +203,7 @@ type progressReader struct {
ch chan int
}
-func newProgressReader(delegate io.Reader) (io.Reader, <-chan int) {
+func newProgressReader(delegate io.Reader) (reader io.Reader, result <-chan int) {
ch := make(chan int)
return &progressReader{
delegate: delegate,
diff --git a/internal/cmd/beta/image/delete/delete.go b/internal/cmd/beta/image/delete/delete.go
index df3411ad3..3e2b44260 100644
--- a/internal/cmd/beta/image/delete/delete.go
+++ b/internal/cmd/beta/image/delete/delete.go
@@ -54,7 +54,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
imageName, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.ImageId)
if err != nil {
- p.Warn("get image name: %v", err)
+ p.Debug(print.ErrorLevel, "get image name: %v", err)
imageName = model.ImageId
}
diff --git a/internal/cmd/beta/image/update/update.go b/internal/cmd/beta/image/update/update.go
index 3dc6ed1d2..9c672e86f 100644
--- a/internal/cmd/beta/image/update/update.go
+++ b/internal/cmd/beta/image/update/update.go
@@ -112,7 +112,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 image "xxx"`, `$ stackit beta image update xxx --labels label1=value1,label2=value2`),
+ 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()
@@ -135,7 +135,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
imageLabel, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Id)
if err != nil {
- p.Warn("cannot retrieve image name: %v", err)
+ p.Debug(print.WarningLevel, "cannot retrieve image name: %v", err)
imageLabel = model.Id
}
From 68e833738de8b313ee101a6dd99fb73a76e89ca5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Wed, 15 Jan 2025 10:10:33 +0100
Subject: [PATCH 14/15] chore: fixed linter hints
---
internal/cmd/beta/image/list/list.go | 2 +-
internal/pkg/utils/strings.go | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/internal/cmd/beta/image/list/list.go b/internal/cmd/beta/image/list/list.go
index 7769d7059..1447f75a7 100644
--- a/internal/cmd/beta/image/list/list.go
+++ b/internal/cmd/beta/image/list/list.go
@@ -177,7 +177,7 @@ func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) err
os,
distro,
version,
- utils.JoinStringKeysPtr(item.Labels, ","))
+ utils.JoinStringKeysPtr(*item.Labels, ","))
}
err := table.Display(p)
if err != nil {
diff --git a/internal/pkg/utils/strings.go b/internal/pkg/utils/strings.go
index db00d9a13..6c33cfc82 100644
--- a/internal/pkg/utils/strings.go
+++ b/internal/pkg/utils/strings.go
@@ -9,7 +9,7 @@ import (
func JoinStringKeys(m map[string]any, sep string) string {
keys := make([]string, len(m))
i := 0
- for k, _ := range m {
+ for k := range m {
keys[i] = k
i++
}
@@ -18,9 +18,9 @@ func JoinStringKeys(m map[string]any, sep string) string {
// JoinStringKeysPtr concatenates the string keys of a map pointer, each separatore by the
// [sep] string.
-func JoinStringKeysPtr(m *map[string]any, sep string) string {
+func JoinStringKeysPtr(m map[string]any, sep string) string {
if m == nil {
return ""
}
- return JoinStringKeys(*m, sep)
+ return JoinStringKeys(m, sep)
}
From 0b871de3c012e164d5f72a34795d2abb50b93e99 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?=
<152157960+bahkauv70@users.noreply.github.com>
Date: Wed, 15 Jan 2025 11:40:48 +0100
Subject: [PATCH 15/15] chore: fix linting warnings, recreated docs again
---
docs/stackit_beta_image_create.md | 7 ++++---
docs/stackit_beta_image_list.md | 4 ++++
docs/stackit_beta_image_update.md | 4 ++--
docs/stackit_ske_kubeconfig_create.md | 6 +++---
internal/cmd/beta/beta.go | 2 +-
5 files changed, 14 insertions(+), 9 deletions(-)
diff --git a/docs/stackit_beta_image_create.md b/docs/stackit_beta_image_create.md
index 5d3fd4b7c..8044adef0 100644
--- a/docs/stackit_beta_image_create.md
+++ b/docs/stackit_beta_image_create.md
@@ -13,11 +13,11 @@ stackit beta image create [flags]
### Examples
```
- Create a named imaged
+ 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 a named image with labels
- $ stackit beta image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image--labels dev,amd64
+ 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
@@ -34,6 +34,7 @@ stackit beta image create [flags]
--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.
diff --git a/docs/stackit_beta_image_list.md b/docs/stackit_beta_image_list.md
index 253e922fa..19ac93016 100644
--- a/docs/stackit_beta_image_list.md
+++ b/docs/stackit_beta_image_list.md
@@ -18,6 +18,9 @@ stackit beta image list [flags]
List images with label
$ stackit beta image list --label-selector ARM64,dev
+
+ List the first 10 images
+ $ stackit beta image list --limit=10
```
### Options
@@ -25,6 +28,7 @@ stackit beta image list [flags]
```
-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
diff --git a/docs/stackit_beta_image_update.md b/docs/stackit_beta_image_update.md
index 63097aa5d..760d561de 100644
--- a/docs/stackit_beta_image_update.md
+++ b/docs/stackit_beta_image_update.md
@@ -13,10 +13,10 @@ stackit beta image update IMAGE_ID [flags]
### Examples
```
- Update the name of image "xxx"
+ Update the name of an image with ID "xxx"
$ stackit beta image update xxx --name my-new-name
- Update the labels of image "xxx"
+ Update the labels of an image with ID "xxx"
$ stackit beta image update xxx --labels label1=value1,label2=value2
```
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 41d00dd1d..966e5f414 100644
--- a/internal/cmd/beta/beta.go
+++ b/internal/cmd/beta/beta.go
@@ -3,13 +3,13 @@ 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"
networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-interface"
publicip "github.com/stackitcloud/stackit-cli/internal/cmd/beta/public-ip"
securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group"
- image "github.com/stackitcloud/stackit-cli/internal/cmd/beta/image"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume"