diff --git a/README.md b/README.md
index a46f741a4..df0cbe601 100644
--- a/README.md
+++ b/README.md
@@ -65,28 +65,28 @@ Help is available for any command by specifying the special flag `--help` (or si
Below you can find a list of the STACKIT services already available in the CLI (along with their respective command names) and the ones that are currently planned to be integrated.
-| Service | CLI Commands | Status |
-| ---------------------------------- | ------------------------- | ------------------------- |
-| Observability | `observability` | :white_check_mark: |
-| Infrastructure as a Service (IaaS) | `beta network-area` | :white_check_mark: (beta) |
-| Authorization | `project`, `organization` | :white_check_mark: |
-| DNS | `dns` | :white_check_mark: |
-| Kubernetes Engine (SKE) | `ske` | :white_check_mark: |
-| Load Balancer | `load-balancer` | :white_check_mark: |
-| LogMe | `logme` | :white_check_mark: |
-| MariaDB | `mariadb` | :white_check_mark: |
-| MongoDB Flex | `mongodbflex` | :white_check_mark: |
-| Object Storage | `object-storage` | :white_check_mark: |
-| OpenSearch | `opensearch` | :white_check_mark: |
-| PostgreSQL Flex | `postgresflex` | :white_check_mark: |
-| RabbitMQ | `rabbitmq` | :white_check_mark: |
-| Redis | `redis` | :white_check_mark: |
-| Resource Manager | `project` | :white_check_mark: |
-| Secrets Manager | `secrets-manager` | :white_check_mark: |
-| Server Backup Management | `beta server backup` | :white_check_mark: (beta) |
-| Server Command (Run Command) | `beta server command` | :white_check_mark: (beta) |
-| Service Account | `service-account` | :white_check_mark: |
-| SQLServer Flex | `beta sqlserverflex` | :white_check_mark: (beta) |
+| Service | CLI Commands | Status |
+| ---------------------------------- |----------------------------------------------------------------| ------------------------- |
+| Observability | `observability` | :white_check_mark: |
+| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume` | :white_check_mark: (beta) |
+| Authorization | `project`, `organization` | :white_check_mark: |
+| DNS | `dns` | :white_check_mark: |
+| Kubernetes Engine (SKE) | `ske` | :white_check_mark: |
+| Load Balancer | `load-balancer` | :white_check_mark: |
+| LogMe | `logme` | :white_check_mark: |
+| MariaDB | `mariadb` | :white_check_mark: |
+| MongoDB Flex | `mongodbflex` | :white_check_mark: |
+| Object Storage | `object-storage` | :white_check_mark: |
+| OpenSearch | `opensearch` | :white_check_mark: |
+| PostgreSQL Flex | `postgresflex` | :white_check_mark: |
+| RabbitMQ | `rabbitmq` | :white_check_mark: |
+| Redis | `redis` | :white_check_mark: |
+| Resource Manager | `project` | :white_check_mark: |
+| Secrets Manager | `secrets-manager` | :white_check_mark: |
+| Server Backup Management | `beta server backup` | :white_check_mark: (beta) |
+| Server Command (Run Command) | `beta server command` | :white_check_mark: (beta) |
+| Service Account | `service-account` | :white_check_mark: |
+| SQLServer Flex | `beta sqlserverflex` | :white_check_mark: (beta) |
## Authentication
diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md
index bf3beb0aa..8da8fef61 100644
--- a/docs/stackit_beta.md
+++ b/docs/stackit_beta.md
@@ -44,4 +44,5 @@ stackit beta [flags]
* [stackit beta network-area](./stackit_beta_network-area.md) - Provides functionality for STACKIT Network Area (SNA)
* [stackit beta server](./stackit_beta_server.md) - Provides functionality for Server
* [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
diff --git a/docs/stackit_beta_volume.md b/docs/stackit_beta_volume.md
new file mode 100644
index 000000000..7255a1cec
--- /dev/null
+++ b/docs/stackit_beta_volume.md
@@ -0,0 +1,39 @@
+## stackit beta volume
+
+Provides functionality for Volume
+
+### Synopsis
+
+Provides functionality for Volume.
+
+```
+stackit beta volume [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands
+* [stackit beta volume create](./stackit_beta_volume_create.md) - Creates a volume
+* [stackit beta volume delete](./stackit_beta_volume_delete.md) - Deletes a volume
+* [stackit beta volume describe](./stackit_beta_volume_describe.md) - Shows details of a volume
+* [stackit beta volume list](./stackit_beta_volume_list.md) - Lists all volumes of a project
+* [stackit beta volume performance-class](./stackit_beta_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project
+* [stackit beta volume resize](./stackit_beta_volume_resize.md) - Resizes a volume
+* [stackit beta volume update](./stackit_beta_volume_update.md) - Updates a volume
+
diff --git a/docs/stackit_beta_volume_create.md b/docs/stackit_beta_volume_create.md
new file mode 100644
index 000000000..b24d2391c
--- /dev/null
+++ b/docs/stackit_beta_volume_create.md
@@ -0,0 +1,53 @@
+## stackit beta volume create
+
+Creates a volume
+
+### Synopsis
+
+Creates a volume.
+
+```
+stackit beta volume create [flags]
+```
+
+### Examples
+
+```
+ Create a volume with availability zone "eu01-1" and size 64 GB
+ $ stackit beta volume create --availability-zone eu01-1 --size 64
+
+ Create a volume with name "volume-1", source id "xxx" and type "image"
+ $ stackit beta volume create --availability-zone eu01-1 --name volume-1 --source-id xxx --source-type image
+
+ Create a volume with availability zone "eu01-1", performance class "storage_premium_perf1" and size 64 GB
+ $ stackit beta volume create --availability-zone eu01-1 --performance-class storage_premium_perf1 --size 64
+```
+
+### Options
+
+```
+ --availability-zone string Availability zone
+ --description string Volume description
+ -h, --help Help for "stackit beta volume create"
+ --label stringToString Labels are key-value string pairs which can be attached to a volume. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default [])
+ -n, --name string Volume name
+ --performance-class string Performance class
+ --size int Volume size (GB). Either 'size' or the 'source-id' and 'source-type' flags must be given
+ --source-id string ID of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given
+ --source-type string Type of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+
diff --git a/docs/stackit_beta_volume_delete.md b/docs/stackit_beta_volume_delete.md
new file mode 100644
index 000000000..1ded8006d
--- /dev/null
+++ b/docs/stackit_beta_volume_delete.md
@@ -0,0 +1,41 @@
+## stackit beta volume delete
+
+Deletes a volume
+
+### Synopsis
+
+Deletes a volume.
+If the volume is still in use, the deletion will fail
+
+
+```
+stackit beta volume delete [flags]
+```
+
+### Examples
+
+```
+ Delete volume with ID "xxx"
+ $ stackit beta volume delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume delete"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+
diff --git a/docs/stackit_beta_volume_describe.md b/docs/stackit_beta_volume_describe.md
new file mode 100644
index 000000000..387edf692
--- /dev/null
+++ b/docs/stackit_beta_volume_describe.md
@@ -0,0 +1,42 @@
+## stackit beta volume describe
+
+Shows details of a volume
+
+### Synopsis
+
+Shows details of a volume.
+
+```
+stackit beta volume describe [flags]
+```
+
+### Examples
+
+```
+ Show details of a volume with ID "xxx"
+ $ stackit beta volume describe xxx
+
+ Show details of a volume with ID "xxx" in JSON format
+ $ stackit beta volume describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume describe"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+
diff --git a/docs/stackit_beta_volume_list.md b/docs/stackit_beta_volume_list.md
new file mode 100644
index 000000000..985db2475
--- /dev/null
+++ b/docs/stackit_beta_volume_list.md
@@ -0,0 +1,50 @@
+## stackit beta volume list
+
+Lists all volumes of a project
+
+### Synopsis
+
+Lists all volumes of a project.
+
+```
+stackit beta volume list [flags]
+```
+
+### Examples
+
+```
+ Lists all volumes
+ $ stackit beta volume list
+
+ Lists all volumes which contains the label xxx
+ $ stackit beta volume list --label-selector xxx
+
+ Lists all volumes in JSON format
+ $ stackit beta volume list --output-format json
+
+ Lists up to 10 volumes
+ $ stackit beta volume list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume list"
+ --label-selector string Filter by label
+ --limit int Maximum number of entries to list
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+
diff --git a/docs/stackit_beta_volume_performance-class.md b/docs/stackit_beta_volume_performance-class.md
new file mode 100644
index 000000000..f1b125cab
--- /dev/null
+++ b/docs/stackit_beta_volume_performance-class.md
@@ -0,0 +1,34 @@
+## stackit beta volume performance-class
+
+Provides functionality for volume performance classes available inside a project
+
+### Synopsis
+
+Provides functionality for volume performance classes available inside a project.
+
+```
+stackit beta volume performance-class [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume performance-class"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+* [stackit beta volume performance-class describe](./stackit_beta_volume_performance-class_describe.md) - Shows details of a volume performance class
+* [stackit beta volume performance-class list](./stackit_beta_volume_performance-class_list.md) - Lists all volume performance classes for a project
+
diff --git a/docs/stackit_beta_volume_performance-class_describe.md b/docs/stackit_beta_volume_performance-class_describe.md
new file mode 100644
index 000000000..902755ea4
--- /dev/null
+++ b/docs/stackit_beta_volume_performance-class_describe.md
@@ -0,0 +1,42 @@
+## stackit beta volume performance-class describe
+
+Shows details of a volume performance class
+
+### Synopsis
+
+Shows details of a volume performance class.
+
+```
+stackit beta volume performance-class describe [flags]
+```
+
+### Examples
+
+```
+ Show details of a volume performance class with name "xxx"
+ $ stackit beta volume performance-class describe xxx
+
+ Show details of a volume performance class with name "xxx" in JSON format
+ $ stackit beta volume performance-class describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume performance-class describe"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume performance-class](./stackit_beta_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project
+
diff --git a/docs/stackit_beta_volume_performance-class_list.md b/docs/stackit_beta_volume_performance-class_list.md
new file mode 100644
index 000000000..a4a12222e
--- /dev/null
+++ b/docs/stackit_beta_volume_performance-class_list.md
@@ -0,0 +1,50 @@
+## stackit beta volume performance-class list
+
+Lists all volume performance classes for a project
+
+### Synopsis
+
+Lists all volume performance classes for a project.
+
+```
+stackit beta volume performance-class list [flags]
+```
+
+### Examples
+
+```
+ Lists all volume performance classes
+ $ stackit beta volume performance-class list
+
+ Lists all volume performance classes which contains the label xxx
+ $ stackit beta volume performance-class list --label-selector xxx
+
+ Lists all volume performance classes in JSON format
+ $ stackit beta volume performance-class list --output-format json
+
+ Lists up to 10 volume performance classes
+ $ stackit beta volume performance-class list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume performance-class list"
+ --label-selector string Filter by label
+ --limit int Maximum number of entries to list
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume performance-class](./stackit_beta_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project
+
diff --git a/docs/stackit_beta_volume_resize.md b/docs/stackit_beta_volume_resize.md
new file mode 100644
index 000000000..c2e7aea0f
--- /dev/null
+++ b/docs/stackit_beta_volume_resize.md
@@ -0,0 +1,40 @@
+## stackit beta volume resize
+
+Resizes a volume
+
+### Synopsis
+
+Resizes a volume.
+
+```
+stackit beta volume resize [flags]
+```
+
+### Examples
+
+```
+ Resize volume with ID "xxx" with new size 10 GB
+ $ stackit beta volume resize xxx --size 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta volume resize"
+ --size int Volume size (GB)
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+
diff --git a/docs/stackit_beta_volume_update.md b/docs/stackit_beta_volume_update.md
new file mode 100644
index 000000000..c8b0ccb44
--- /dev/null
+++ b/docs/stackit_beta_volume_update.md
@@ -0,0 +1,45 @@
+## stackit beta volume update
+
+Updates a volume
+
+### Synopsis
+
+Updates a volume.
+
+```
+stackit beta volume update [flags]
+```
+
+### Examples
+
+```
+ Update volume with ID "xxx" with new name "volume-1-new"
+ $ stackit beta volume update xxx --name volume-1-new
+
+ Update volume with ID "xxx" with new name "volume-1-new" and new description "volume-1-desc-new"
+ $ stackit beta volume update xxx --name volume-1-new --description volume-1-desc-new
+```
+
+### Options
+
+```
+ --description string Volume description
+ -h, --help Help for "stackit beta volume update"
+ --label stringToString Labels are key-value string pairs which can be attached to a volume. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default [])
+ -n, --name string Volume name
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume
+
diff --git a/go.mod b/go.mod
index 4be13c730..2cecb5db1 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,7 @@ require (
github.com/stackitcloud/stackit-sdk-go/core v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/authorization v0.4.0
github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0
- github.com/stackitcloud/stackit-sdk-go/services/iaas v0.15.0
+ github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0
github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.16.0
github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.19.0
github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.16.0
diff --git a/go.sum b/go.sum
index 276982bac..aa2006847 100644
--- a/go.sum
+++ b/go.sum
@@ -133,6 +133,8 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0 h1:+OZ82DwFy4JIJThad
github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0/go.mod h1:mv8U7kuclXo+0VpDHtBCkve/3i9h1yT+RAId/MUi+C8=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.15.0 h1:bPNv+PuSykBcKCYVXHiYOcqNP+KLCA7XMFSY4V6J6ug=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.15.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM=
+github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0 h1:geyW780gqNxzSsPvmlxy3kUUJaRA4eiF9V3b2Ibcdjs=
+github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM=
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 h1:06CGP64CEk3Zg6i9kZCMRdmCzLLiyMWQqGK1teBr9Oc=
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0/go.mod h1:JL94zc8K0ebWs+DBGXR28vNCF0EFV54ZLUtrlXOvWgA=
github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 h1:V0UGP7JEa4Q8SsZFUJsKgLGaoPruLn2KVKnqQtaoWCU=
diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go
index 348118936..e1a16ccdb 100644
--- a/internal/cmd/beta/beta.go
+++ b/internal/cmd/beta/beta.go
@@ -7,6 +7,7 @@ import (
networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-area"
"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"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
@@ -42,4 +43,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
cmd.AddCommand(server.NewCmd(p))
cmd.AddCommand(networkArea.NewCmd(p))
cmd.AddCommand(network.NewCmd(p))
+ cmd.AddCommand(volume.NewCmd(p))
}
diff --git a/internal/cmd/beta/volume/create/create.go b/internal/cmd/beta/volume/create/create.go
new file mode 100644
index 000000000..88467c54e
--- /dev/null
+++ b/internal/cmd/beta/volume/create/create.go
@@ -0,0 +1,219 @@
+package create
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/goccy/go-yaml"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/flags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ availabilityZoneFlag = "availability-zone"
+ nameFlag = "name"
+ descriptionFlag = "description"
+ labelFlag = "label"
+ performanceClassFlag = "performance-class"
+ sizeFlag = "size"
+ sourceIdFlag = "source-id"
+ sourceTypeFlag = "source-type"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ AvailabilityZone *string
+ Name *string
+ Description *string
+ Labels *map[string]string
+ PerformanceClass *string
+ Size *int64
+ SourceId *string
+ SourceType *string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a volume",
+ Long: "Creates a volume.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a volume with availability zone "eu01-1" and size 64 GB`,
+ `$ stackit beta volume create --availability-zone eu01-1 --size 64`,
+ ),
+ examples.NewExample(
+ `Create a volume with name "volume-1", from a source image with ID "xxx"`,
+ `$ stackit beta volume create --availability-zone eu01-1 --name volume-1 --source-id xxx --source-type image`,
+ ),
+ examples.NewExample(
+ `Create a volume with availability zone "eu01-1", performance class "storage_premium_perf1" and size 64 GB`,
+ `$ stackit beta volume create --availability-zone eu01-1 --performance-class storage_premium_perf1 --size 64`,
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ return err
+ }
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ projectLabel, err := projectname.GetProjectName(ctx, p, cmd)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get project name: %v", err)
+ projectLabel = model.ProjectId
+ }
+
+ if !model.AssumeYes {
+ prompt := fmt.Sprintf("Are you sure you want to create a volume for project %q?", projectLabel)
+ err = p.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create volume : %w", err)
+ }
+ volumeId := *resp.Id
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(p)
+ s.Start("Creating volume")
+ _, err = wait.CreateVolumeWaitHandler(ctx, apiClient, model.ProjectId, volumeId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for volume creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(p, model, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(availabilityZoneFlag, "", "Availability zone")
+ cmd.Flags().StringP(nameFlag, "n", "", "Volume name")
+ cmd.Flags().String(descriptionFlag, "", "Volume description")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a volume. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels")
+ cmd.Flags().String(performanceClassFlag, "", "Performance class")
+ cmd.Flags().Int64(sizeFlag, 0, "Volume size (GB). Either 'size' or the 'source-id' and 'source-type' flags must be given")
+ cmd.Flags().String(sourceIdFlag, "", "ID of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given")
+ cmd.Flags().String(sourceTypeFlag, "", "Type of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given")
+
+ err := flags.MarkFlagsRequired(cmd, availabilityZoneFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ AvailabilityZone: flags.FlagToStringPointer(p, cmd, availabilityZoneFlag),
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ PerformanceClass: flags.FlagToStringPointer(p, cmd, performanceClassFlag),
+ Size: flags.FlagToInt64Pointer(p, cmd, sizeFlag),
+ SourceId: flags.FlagToStringPointer(p, cmd, sourceIdFlag),
+ SourceType: flags.FlagToStringPointer(p, cmd, sourceTypeFlag),
+ }
+
+ 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.ApiCreateVolumeRequest {
+ req := apiClient.CreateVolume(ctx, model.ProjectId)
+ source := &iaas.VolumeSource{
+ Id: model.SourceId,
+ Type: model.SourceType,
+ }
+
+ var labelsMap *map[string]interface{}
+ if model.Labels != nil && len(*model.Labels) > 0 {
+ // convert map[string]string to map[string]interface{}
+ labelsMap = utils.Ptr(map[string]interface{}{})
+ for k, v := range *model.Labels {
+ (*labelsMap)[k] = v
+ }
+ }
+
+ payload := iaas.CreateVolumePayload{
+ AvailabilityZone: model.AvailabilityZone,
+ Name: model.Name,
+ Description: model.Description,
+ Labels: labelsMap,
+ PerformanceClass: model.PerformanceClass,
+ Size: model.Size,
+ }
+
+ if model.SourceId != nil && model.SourceType != nil {
+ payload.Source = source
+ }
+
+ return req.CreateVolumePayload(payload)
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel string, volume *iaas.Volume) error {
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(volume, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ p.Outputf("Created volume for project %q.\nVolume ID: %s\n", projectLabel, *volume.Id)
+ return nil
+ }
+}
diff --git a/internal/cmd/beta/volume/create/create_test.go b/internal/cmd/beta/volume/create/create_test.go
new file mode 100644
index 000000000..c9fecec3a
--- /dev/null
+++ b/internal/cmd/beta/volume/create/create_test.go
@@ -0,0 +1,286 @@
+package create
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testSourceId = uuid.NewString()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ availabilityZoneFlag: "eu01-1",
+ nameFlag: "example-volume-name",
+ descriptionFlag: "example-volume-description",
+ labelFlag: "key=value",
+ performanceClassFlag: "example-perf-class",
+ sizeFlag: "5",
+ sourceIdFlag: testSourceId,
+ sourceTypeFlag: "example-source-type",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-description"),
+ PerformanceClass: utils.Ptr("example-perf-class"),
+ Size: utils.Ptr(int64(5)),
+ SourceId: utils.Ptr(testSourceId),
+ SourceType: utils.Ptr("example-source-type"),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.ApiCreateVolumeRequest {
+ request := testClient.CreateVolume(testCtx, testProjectId)
+ request = request.CreateVolumePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.ApiCreateVolumeRequest {
+ request := testClient.CreateVolume(testCtx, testProjectId)
+ request = request.CreateVolumePayload(iaas.CreateVolumePayload{
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ })
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateVolumePayload)) iaas.CreateVolumePayload {
+ payload := iaas.CreateVolumePayload{
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-description"),
+ PerformanceClass: utils.Ptr("example-perf-class"),
+ Size: utils.Ptr(int64(5)),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ Source: &iaas.VolumeSource{
+ Id: utils.Ptr(testSourceId),
+ Type: utils.Ptr("example-source-type"),
+ },
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "required only",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nameFlag)
+ delete(flagValues, descriptionFlag)
+ delete(flagValues, labelFlag)
+ delete(flagValues, performanceClassFlag)
+ delete(flagValues, sizeFlag)
+ delete(flagValues, sourceIdFlag)
+ delete(flagValues, sourceTypeFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = nil
+ model.Description = nil
+ model.Labels = nil
+ model.PerformanceClass = nil
+ model.Size = nil
+ model.SourceType = nil
+ model.SourceId = nil
+ }),
+ },
+ {
+ description: "availability zone missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, availabilityZoneFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "no values",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "use performance class and size",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[performanceClassFlag] = "example-perf-class"
+ flagValues[sizeFlag] = "5"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.PerformanceClass = utils.Ptr("example-perf-class")
+ model.Size = utils.Ptr(int64(5))
+ }),
+ },
+ {
+ description: "use source id and type",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[sourceIdFlag] = testSourceId
+ flagValues[sourceTypeFlag] = "example-source-type"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.SourceId = utils.Ptr(testSourceId)
+ model.SourceType = utils.Ptr("example-source-type")
+ }),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing flags: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiCreateVolumeRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "only availability zone in payload",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ },
+ expectedRequest: fixtureRequiredRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/volume/delete/delete.go b/internal/cmd/beta/volume/delete/delete.go
new file mode 100644
index 000000000..ce170565c
--- /dev/null
+++ b/internal/cmd/beta/volume/delete/delete.go
@@ -0,0 +1,129 @@
+package delete
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Deletes a volume",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a volume.",
+ "If the volume is still in use, the deletion will fail",
+ ),
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete volume with ID "xxx"`,
+ "$ stackit beta volume 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
+ }
+
+ volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get volume name: %v", err)
+ volumeLabel = model.VolumeId
+ }
+
+ if !model.AssumeYes {
+ prompt := fmt.Sprintf("Are you sure you want to delete volume %q?", volumeLabel)
+ err = p.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete volume: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(p)
+ s.Start("Deleting volume")
+ _, err = wait.DeleteVolumeWaitHandler(ctx, apiClient, model.ProjectId, model.VolumeId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for volume deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Deleted"
+ if model.Async {
+ operationState = "Triggered deletion of"
+ }
+ p.Info("%s volume %q\n", operationState, volumeLabel)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumeId: volumeId,
+ }
+
+ 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.ApiDeleteVolumeRequest {
+ return apiClient.DeleteVolume(ctx, model.ProjectId, model.VolumeId)
+}
diff --git a/internal/cmd/beta/volume/delete/delete_test.go b/internal/cmd/beta/volume/delete/delete_test.go
new file mode 100644
index 000000000..5648c374c
--- /dev/null
+++ b/internal/cmd/beta/volume/delete/delete_test.go
@@ -0,0 +1,218 @@
+package delete
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testVolumeId = uuid.NewString()
+var testProjectId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ },
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteVolumeRequest)) iaas.ApiDeleteVolumeRequest {
+ request := testClient.DeleteVolume(testCtx, testProjectId, testVolumeId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateArgs(tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating args: %v", err)
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd, tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiDeleteVolumeRequest
+ }{
+ {
+ 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/volume/describe/describe.go b/internal/cmd/beta/volume/describe/describe.go
new file mode 100644
index 000000000..0fc48e135
--- /dev/null
+++ b/internal/cmd/beta/volume/describe/describe.go
@@ -0,0 +1,154 @@
+package describe
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/goccy/go-yaml"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "describe",
+ Short: "Shows details of a volume",
+ Long: "Shows details of a volume.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a volume with ID "xxx"`,
+ "$ stackit beta volume describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a volume with ID "xxx" in JSON format`,
+ "$ stackit beta volume describe xxx --output-format json",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd, args)
+ if err != nil {
+ return err
+ }
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("read volume: %w", err)
+ }
+
+ return outputResult(p, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumeId: volumeId,
+ }
+
+ 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.ApiGetVolumeRequest {
+ return apiClient.GetVolume(ctx, model.ProjectId, model.VolumeId)
+}
+
+func outputResult(p *print.Printer, outputFormat string, volume *iaas.Volume) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(volume, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ table := tables.NewTable()
+ table.AddRow("ID", *volume.Id)
+ table.AddSeparator()
+ table.AddRow("NAME", *volume.Name)
+ table.AddSeparator()
+ table.AddRow("STATE", *volume.Status)
+ table.AddSeparator()
+ table.AddRow("VOLUME SIZE (GB)", *volume.Size)
+ table.AddSeparator()
+ table.AddRow("PERFORMANCE CLASS", *volume.PerformanceClass)
+ table.AddSeparator()
+ table.AddRow("AVAILABILITY ZONE", *volume.AvailabilityZone)
+ table.AddSeparator()
+
+ if volume.Source != nil {
+ sourceId := *volume.Source.Id
+ table.AddRow("SOURCE", sourceId)
+ table.AddSeparator()
+ }
+
+ if volume.ServerId != nil {
+ serverId := *volume.ServerId
+ table.AddRow("SERVER", serverId)
+ table.AddSeparator()
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+ return nil
+ }
+}
diff --git a/internal/cmd/beta/volume/describe/describe_test.go b/internal/cmd/beta/volume/describe/describe_test.go
new file mode 100644
index 000000000..8f3fcdd57
--- /dev/null
+++ b/internal/cmd/beta/volume/describe/describe_test.go
@@ -0,0 +1,218 @@
+package describe
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetVolumeRequest)) iaas.ApiGetVolumeRequest {
+ request := testClient.GetVolume(testCtx, testProjectId, testVolumeId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateArgs(tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating args: %v", err)
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd, tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetVolumeRequest
+ }{
+ {
+ 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/volume/list/list.go b/internal/cmd/beta/volume/list/list.go
new file mode 100644
index 000000000..1594b390a
--- /dev/null
+++ b/internal/cmd/beta/volume/list/list.go
@@ -0,0 +1,181 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/goccy/go-yaml"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/flags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ limitFlag = "limit"
+ labelSelectorFlag = "label-selector"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ LabelSelector *string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all volumes of a project",
+ Long: "Lists all volumes of a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all volumes`,
+ "$ stackit beta volume list",
+ ),
+ examples.NewExample(
+ `Lists all volumes which contains the label xxx`,
+ "$ stackit beta volume list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all volumes in JSON format`,
+ "$ stackit beta volume list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 volumes`,
+ "$ stackit beta volume list --limit 10",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ return err
+ }
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list volumes: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ projectLabel, err := projectname.GetProjectName(ctx, p, cmd)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get project name: %v", err)
+ projectLabel = model.ProjectId
+ }
+ p.Info("No volumes found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ if model.Limit != nil && len(items) > int(*model.Limit) {
+ items = items[:*model.Limit]
+ }
+
+ return outputResult(p, model.OutputFormat, items)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ limit := flags.FlagToInt64Pointer(p, cmd, limitFlag)
+ if limit != nil && *limit < 1 {
+ return nil, &errors.FlagValidationError{
+ Flag: limitFlag,
+ Details: "must be greater than 0",
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Limit: limit,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ 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.ApiListVolumesRequest {
+ req := apiClient.ListVolumes(ctx, model.ProjectId)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, volumes []iaas.Volume) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(volumes, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(volumes, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ table := tables.NewTable()
+ table.SetHeader("ID", "Name", "Status", "Server", "Availability Zone", "Size (GB)")
+
+ for _, volume := range volumes {
+ serverId := ""
+ if volume.ServerId != nil {
+ serverId = *volume.ServerId
+ }
+ table.AddRow(*volume.Id, *volume.Name, *volume.Status, serverId, *volume.AvailabilityZone, *volume.Size)
+ table.AddSeparator()
+ }
+
+ p.Outputln(table.Render())
+ return nil
+ }
+}
diff --git a/internal/cmd/beta/volume/list/list_test.go b/internal/cmd/beta/volume/list/list_test.go
new file mode 100644
index 000000000..ea1b60ad3
--- /dev/null
+++ b/internal/cmd/beta/volume/list/list_test.go
@@ -0,0 +1,204 @@
+package list
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListVolumesRequest)) iaas.ApiListVolumesRequest {
+ request := testClient.ListVolumes(testCtx, testProjectId)
+ request = request.LabelSelector(testLabelSelector)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "limit invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "invalid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "limit invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "0"
+ }),
+ isValid: false,
+ },
+ {
+ description: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiListVolumesRequest
+ }{
+ {
+ 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/volume/performance-class/describe/describe.go b/internal/cmd/beta/volume/performance-class/describe/describe.go
new file mode 100644
index 000000000..a316babba
--- /dev/null
+++ b/internal/cmd/beta/volume/performance-class/describe/describe.go
@@ -0,0 +1,137 @@
+package describe
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/goccy/go-yaml"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/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-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ volumePerformanceClassArg = "VOLUME_PERFORMANCE_CLASS"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumePerformanceClass string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "describe",
+ Short: "Shows details of a volume performance class",
+ Long: "Shows details of a volume performance class.",
+ Args: args.SingleArg(volumePerformanceClassArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a volume performance class with name "xxx"`,
+ "$ stackit beta volume performance-class describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a volume performance class with name "xxx" in JSON format`,
+ "$ stackit beta volume performance-class describe xxx --output-format json",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd, args)
+ if err != nil {
+ return err
+ }
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("read volume performance class: %w", err)
+ }
+
+ return outputResult(p, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumePerformanceClass := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumePerformanceClass: volumePerformanceClass,
+ }
+
+ 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.ApiGetVolumePerformanceClassRequest {
+ return apiClient.GetVolumePerformanceClass(ctx, model.ProjectId, model.VolumePerformanceClass)
+}
+
+func outputResult(p *print.Printer, outputFormat string, performanceClass *iaas.VolumePerformanceClass) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(performanceClass, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal volume performance class: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(performanceClass, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal volume performance class: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ table := tables.NewTable()
+ table.AddRow("NAME", *performanceClass.Name)
+ table.AddSeparator()
+ table.AddRow("DESCRIPTION", *performanceClass.Description)
+ table.AddSeparator()
+ table.AddRow("IOPS", *performanceClass.Iops)
+ table.AddSeparator()
+ table.AddRow("THROUGHPUT", *performanceClass.Throughput)
+ table.AddSeparator()
+
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+ return nil
+ }
+}
diff --git a/internal/cmd/beta/volume/performance-class/describe/describe_test.go b/internal/cmd/beta/volume/performance-class/describe/describe_test.go
new file mode 100644
index 000000000..2a43beaf1
--- /dev/null
+++ b/internal/cmd/beta/volume/performance-class/describe/describe_test.go
@@ -0,0 +1,212 @@
+package describe
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testVolumePerformanceClass = "storage_premium_perf6"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumePerformanceClass,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ VolumePerformanceClass: testVolumePerformanceClass,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetVolumePerformanceClassRequest)) iaas.ApiGetVolumePerformanceClassRequest {
+ request := testClient.GetVolumePerformanceClass(testCtx, testProjectId, testVolumePerformanceClass)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume performance class invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateArgs(tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating args: %v", err)
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd, tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetVolumePerformanceClassRequest
+ }{
+ {
+ 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/volume/performance-class/list/list.go b/internal/cmd/beta/volume/performance-class/list/list.go
new file mode 100644
index 000000000..3bb62e9d7
--- /dev/null
+++ b/internal/cmd/beta/volume/performance-class/list/list.go
@@ -0,0 +1,177 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/goccy/go-yaml"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/flags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ limitFlag = "limit"
+ labelSelectorFlag = "label-selector"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ LabelSelector *string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all volume performance classes for a project",
+ Long: "Lists all volume performance classes for a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all volume performance classes`,
+ "$ stackit beta volume performance-class list",
+ ),
+ examples.NewExample(
+ `Lists all volume performance classes which contains the label xxx`,
+ "$ stackit beta volume performance-class list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all volume performance classes in JSON format`,
+ "$ stackit beta volume performance-class list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 volume performance classes`,
+ "$ stackit beta volume performance-class list --limit 10",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ return err
+ }
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list volume performance classes: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ projectLabel, err := projectname.GetProjectName(ctx, p, cmd)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get project name: %v", err)
+ projectLabel = model.ProjectId
+ }
+ p.Info("No volume performance class found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ if model.Limit != nil && len(items) > int(*model.Limit) {
+ items = items[:*model.Limit]
+ }
+
+ return outputResult(p, model.OutputFormat, items)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ limit := flags.FlagToInt64Pointer(p, cmd, limitFlag)
+ if limit != nil && *limit < 1 {
+ return nil, &errors.FlagValidationError{
+ Flag: limitFlag,
+ Details: "must be greater than 0",
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Limit: limit,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ 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.ApiListVolumePerformanceClassesRequest {
+ req := apiClient.ListVolumePerformanceClasses(ctx, model.ProjectId)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, performanceClasses []iaas.VolumePerformanceClass) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(performanceClasses, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal volume performance class: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(performanceClasses, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal volume performance class: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ table := tables.NewTable()
+ table.SetHeader("Name", "Description")
+
+ for _, performanceClass := range performanceClasses {
+ table.AddRow(*performanceClass.Name, *performanceClass.Description)
+ table.AddSeparator()
+ }
+
+ p.Outputln(table.Render())
+ return nil
+ }
+}
diff --git a/internal/cmd/beta/volume/performance-class/list/list_test.go b/internal/cmd/beta/volume/performance-class/list/list_test.go
new file mode 100644
index 000000000..91c2041bb
--- /dev/null
+++ b/internal/cmd/beta/volume/performance-class/list/list_test.go
@@ -0,0 +1,204 @@
+package list
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListVolumePerformanceClassesRequest)) iaas.ApiListVolumePerformanceClassesRequest {
+ request := testClient.ListVolumePerformanceClasses(testCtx, testProjectId)
+ request = request.LabelSelector(testLabelSelector)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "limit invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "invalid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "limit invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "0"
+ }),
+ isValid: false,
+ },
+ {
+ description: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiListVolumePerformanceClassesRequest
+ }{
+ {
+ 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/volume/performance-class/performance_class.go b/internal/cmd/beta/volume/performance-class/performance_class.go
new file mode 100644
index 000000000..8df1e19fe
--- /dev/null
+++ b/internal/cmd/beta/volume/performance-class/performance_class.go
@@ -0,0 +1,28 @@
+package performanceclass
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/performance-class/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/performance-class/list"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "performance-class",
+ Short: "Provides functionality for volume performance classes available inside a project",
+ Long: "Provides functionality for volume performance classes available inside a project.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, p)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, p *print.Printer) {
+ cmd.AddCommand(describe.NewCmd(p))
+ cmd.AddCommand(list.NewCmd(p))
+}
diff --git a/internal/cmd/beta/volume/resize/resize.go b/internal/cmd/beta/volume/resize/resize.go
new file mode 100644
index 000000000..043ca14fe
--- /dev/null
+++ b/internal/cmd/beta/volume/resize/resize.go
@@ -0,0 +1,128 @@
+package resize
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/flags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+
+ sizeFlag = "size"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+ Size *int64
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "resize",
+ Short: "Resizes a volume",
+ Long: "Resizes a volume.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Resize volume with ID "xxx" with new size 10 GB`,
+ `$ stackit beta volume resize xxx --size 10`,
+ ),
+ ),
+ 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
+ }
+
+ volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get volume name: %v", err)
+ volumeLabel = model.VolumeId
+ }
+
+ if !model.AssumeYes {
+ prompt := fmt.Sprintf("Are you sure you want to resize volume %q?", volumeLabel)
+ err = p.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("resize volume: %w", err)
+ }
+
+ p.Outputf("Resized volume %q.\n", volumeLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(sizeFlag, 0, "Volume size (GB)")
+
+ err := flags.MarkFlagsRequired(cmd, sizeFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Size: flags.FlagToInt64Pointer(p, cmd, sizeFlag),
+ VolumeId: volumeId,
+ }
+
+ 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.ApiResizeVolumeRequest {
+ req := apiClient.ResizeVolume(ctx, model.ProjectId, model.VolumeId)
+
+ payload := iaas.ResizeVolumePayload{
+ Size: model.Size,
+ }
+
+ return req.ResizeVolumePayload(payload)
+}
diff --git a/internal/cmd/beta/volume/resize/resize_test.go b/internal/cmd/beta/volume/resize/resize_test.go
new file mode 100644
index 000000000..15dd9c757
--- /dev/null
+++ b/internal/cmd/beta/volume/resize/resize_test.go
@@ -0,0 +1,232 @@
+package resize
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ sizeFlag: "10",
+ 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,
+ },
+ Size: utils.Ptr(int64(10)),
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiResizeVolumeRequest)) iaas.ApiResizeVolumeRequest {
+ request := testClient.ResizeVolume(testCtx, testProjectId, testVolumeId)
+ request = request.ResizeVolumePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.ResizeVolumePayload)) iaas.ResizeVolumePayload {
+ payload := iaas.ResizeVolumePayload{
+ Size: utils.Ptr(int64(10)),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "resize",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[sizeFlag] = "15"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Size = utils.Ptr(int64(15))
+ }),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ err = cmd.ValidateArgs(tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating args: %v", err)
+ }
+
+ model, err := parseInput(p, cmd, tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing flags: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiResizeVolumeRequest
+ }{
+ {
+ 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/volume/update/update.go b/internal/cmd/beta/volume/update/update.go
new file mode 100644
index 000000000..cdd8fc8d8
--- /dev/null
+++ b/internal/cmd/beta/volume/update/update.go
@@ -0,0 +1,173 @@
+package update
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/goccy/go-yaml"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/flags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+ iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+
+ nameFlag = "name"
+ descriptionFlag = "description"
+ labelFlag = "label"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+ Name *string
+ Description *string
+ Labels *map[string]string
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Updates a volume",
+ Long: "Updates a volume.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update volume with ID "xxx" with new name "volume-1-new"`,
+ `$ stackit beta volume update xxx --name volume-1-new`,
+ ),
+ examples.NewExample(
+ `Update volume with ID "xxx" with new name "volume-1-new" and new description "volume-1-desc-new"`,
+ `$ stackit beta volume update xxx --name volume-1-new --description volume-1-desc-new`,
+ ),
+ ),
+ 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
+ }
+
+ volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get volume name: %v", err)
+ volumeLabel = model.VolumeId
+ }
+
+ if !model.AssumeYes {
+ prompt := fmt.Sprintf("Are you sure you want to update volume %q?", volumeLabel)
+ 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 volume: %w", err)
+ }
+
+ return outputResult(p, model, volumeLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(nameFlag, "n", "", "Volume name")
+ cmd.Flags().String(descriptionFlag, "", "Volume description")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a volume. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ VolumeId: volumeId,
+ Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ if p.IsVerbosityDebug() {
+ modelStr, err := print.BuildDebugStrFromInputModel(model)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
+ } else {
+ p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
+ }
+ }
+
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateVolumeRequest {
+ req := apiClient.UpdateVolume(ctx, model.ProjectId, model.VolumeId)
+
+ var labelsMap *map[string]interface{}
+ if model.Labels != nil && len(*model.Labels) > 0 {
+ // convert map[string]string to map[string]interface{}
+ labelsMap = utils.Ptr(map[string]interface{}{})
+ for k, v := range *model.Labels {
+ (*labelsMap)[k] = v
+ }
+ }
+
+ payload := iaas.UpdateVolumePayload{
+ Name: model.Name,
+ Description: model.Description,
+ Labels: labelsMap,
+ }
+
+ return req.UpdateVolumePayload(payload)
+}
+
+func outputResult(p *print.Printer, model *inputModel, volumeLabel string, volume *iaas.Volume) error {
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(volume, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal volume: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ p.Outputf("Updated volume %q.\n", volumeLabel)
+ return nil
+ }
+}
diff --git a/internal/cmd/beta/volume/update/update_test.go b/internal/cmd/beta/volume/update/update_test.go
new file mode 100644
index 000000000..283bfda43
--- /dev/null
+++ b/internal/cmd/beta/volume/update/update_test.go
@@ -0,0 +1,257 @@
+package update
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ nameFlag: "example-volume-name",
+ projectIdFlag: testProjectId,
+ descriptionFlag: "example-volume-desc",
+ labelFlag: "key=value",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-desc"),
+ VolumeId: testVolumeId,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateVolumeRequest)) iaas.ApiUpdateVolumeRequest {
+ request := testClient.UpdateVolume(testCtx, testProjectId, testVolumeId)
+ request = request.UpdateVolumePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateVolumePayload)) iaas.UpdateVolumePayload {
+ payload := iaas.UpdateVolumePayload{
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-desc"),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "use name and description",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "example-volume-name"
+ flagValues[descriptionFlag] = "example-volume-desc"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = utils.Ptr("example-volume-name")
+ model.Description = utils.Ptr("example-volume-desc")
+ }),
+ },
+ {
+ description: "use labels",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelFlag] = "key=value"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "key": "value",
+ }
+ }),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ err = cmd.ValidateArgs(tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating args: %v", err)
+ }
+
+ model, err := parseInput(p, cmd, tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing flags: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiUpdateVolumeRequest
+ }{
+ {
+ 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/volume/volume.go b/internal/cmd/beta/volume/volume.go
new file mode 100644
index 000000000..0fa6f59f2
--- /dev/null
+++ b/internal/cmd/beta/volume/volume.go
@@ -0,0 +1,38 @@
+package volume
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/list"
+ performanceclass "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/performance-class"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/resize"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume/update"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "volume",
+ Short: "Provides functionality for Volume",
+ Long: "Provides functionality for Volume.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, p)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, p *print.Printer) {
+ cmd.AddCommand(create.NewCmd(p))
+ cmd.AddCommand(delete.NewCmd(p))
+ cmd.AddCommand(describe.NewCmd(p))
+ cmd.AddCommand(list.NewCmd(p))
+ cmd.AddCommand(update.NewCmd(p))
+ cmd.AddCommand(resize.NewCmd(p))
+ cmd.AddCommand(performanceclass.NewCmd(p))
+}
diff --git a/internal/pkg/services/iaas/utils/utils.go b/internal/pkg/services/iaas/utils/utils.go
index 44807a4fc..9e7d79510 100644
--- a/internal/pkg/services/iaas/utils/utils.go
+++ b/internal/pkg/services/iaas/utils/utils.go
@@ -8,12 +8,21 @@ import (
)
type IaaSClient interface {
+ GetVolumeExecute(ctx context.Context, projectId, volumeId string) (*iaas.Volume, error)
GetNetworkExecute(ctx context.Context, projectId, networkId string) (*iaas.Network, error)
GetNetworkAreaExecute(ctx context.Context, organizationId, areaId string) (*iaas.NetworkArea, error)
ListNetworkAreaProjectsExecute(ctx context.Context, organizationId, areaId string) (*iaas.ProjectListResponse, error)
GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, networkRangeId string) (*iaas.NetworkRange, error)
}
+func GetVolumeName(ctx context.Context, apiClient IaaSClient, projectId, volumeId string) (string, error) {
+ resp, err := apiClient.GetVolumeExecute(ctx, projectId, volumeId)
+ if err != nil {
+ return "", fmt.Errorf("get volume: %w", err)
+ }
+ return *resp.Name, nil
+}
+
func GetNetworkName(ctx context.Context, apiClient IaaSClient, projectId, networkId string) (string, error) {
resp, err := apiClient.GetNetworkExecute(ctx, projectId, networkId)
if err != nil {
diff --git a/internal/pkg/services/iaas/utils/utils_test.go b/internal/pkg/services/iaas/utils/utils_test.go
index 4caa3c7fc..9c0e41ac1 100644
--- a/internal/pkg/services/iaas/utils/utils_test.go
+++ b/internal/pkg/services/iaas/utils/utils_test.go
@@ -11,6 +11,8 @@ import (
)
type IaaSClientMocked struct {
+ GetVolumeFails bool
+ GetVolumeResp *iaas.Volume
GetNetworkFails bool
GetNetworkResp *iaas.Network
GetNetworkAreaFails bool
@@ -21,6 +23,13 @@ type IaaSClientMocked struct {
GetNetworkAreaRangeResp *iaas.NetworkRange
}
+func (m *IaaSClientMocked) GetVolumeExecute(_ context.Context, _, _ string) (*iaas.Volume, error) {
+ if m.GetVolumeFails {
+ return nil, fmt.Errorf("could not get volume")
+ }
+ return m.GetVolumeResp, nil
+}
+
func (m *IaaSClientMocked) GetNetworkExecute(_ context.Context, _, _ string) (*iaas.Network, error) {
if m.GetNetworkFails {
return nil, fmt.Errorf("could not get network")
@@ -49,6 +58,52 @@ func (m *IaaSClientMocked) GetNetworkAreaRangeExecute(_ context.Context, _, _, _
return m.GetNetworkAreaRangeResp, nil
}
+func TestGetVolumeName(t *testing.T) {
+ type args struct {
+ getInstanceFails bool
+ getInstanceResp *iaas.Volume
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.Volume{
+ Name: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get volume fails",
+ args: args{
+ getInstanceFails: true,
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetVolumeFails: tt.args.getInstanceFails,
+ GetVolumeResp: tt.args.getInstanceResp,
+ }
+ got, err := GetVolumeName(context.Background(), m, "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetVolumeName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetVolumeName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestGetNetworkName(t *testing.T) {
type args struct {
getInstanceFails bool