diff --git a/README.md b/README.md
index b00887fd9..d7f76ff26 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,7 @@ Below you can find a list of the STACKIT services already available in the CLI (
| Service | CLI Commands | Status |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| Observability | `observability` | :white_check_mark: |
-| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume`
`beta network-interface`
`beta public-ip`
`beta security-group`
`beta key-pair`
`beta image` | :white_check_mark: (beta)|
+| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume`
`beta network-interface`
`beta public-ip`
`beta security-group`
`beta key-pair`
`beta image`
`beta quota` | :white_check_mark: (beta)|
| Authorization | `project`, `organization` | :white_check_mark: |
| DNS | `dns` | :white_check_mark: |
| Kubernetes Engine (SKE) | `ske` | :white_check_mark: |
diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md
index d5ed983dc..fde3ac259 100644
--- a/docs/stackit_beta.md
+++ b/docs/stackit_beta.md
@@ -47,6 +47,7 @@ stackit beta [flags]
* [stackit beta network-area](./stackit_beta_network-area.md) - Provides functionality for STACKIT Network Area (SNA)
* [stackit beta network-interface](./stackit_beta_network-interface.md) - Provides functionality for network interfaces
* [stackit beta public-ip](./stackit_beta_public-ip.md) - Provides functionality for public IPs
+* [stackit beta quota](./stackit_beta_quota.md) - Manage server quotas
* [stackit beta security-group](./stackit_beta_security-group.md) - Manage security groups
* [stackit beta server](./stackit_beta_server.md) - Provides functionality for servers
* [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex
diff --git a/docs/stackit_beta_quota.md b/docs/stackit_beta_quota.md
new file mode 100644
index 000000000..3fa8e4a33
--- /dev/null
+++ b/docs/stackit_beta_quota.md
@@ -0,0 +1,34 @@
+## stackit beta quota
+
+Manage server quotas
+
+### Synopsis
+
+Manage the lifecycle of server quotas.
+
+```
+stackit beta quota [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta quota"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
+ -p, --project-id string Project ID
+ --region string Target region for region-specific requests
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands
+* [stackit beta quota list](./stackit_beta_quota_list.md) - Lists quotas
+
diff --git a/docs/stackit_beta_quota_list.md b/docs/stackit_beta_quota_list.md
new file mode 100644
index 000000000..f9a6fa62c
--- /dev/null
+++ b/docs/stackit_beta_quota_list.md
@@ -0,0 +1,40 @@
+## stackit beta quota list
+
+Lists quotas
+
+### Synopsis
+
+Lists server quotas.
+
+```
+stackit beta quota list [flags]
+```
+
+### Examples
+
+```
+ List available quotas
+ $ stackit beta quota list
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta quota 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
+ --region string Target region for region-specific requests
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta quota](./stackit_beta_quota.md) - Manage server quotas
+
diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go
index 966e5f414..72ed0c5f2 100644
--- a/internal/cmd/beta/beta.go
+++ b/internal/cmd/beta/beta.go
@@ -9,6 +9,7 @@ import (
networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-area"
networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-interface"
publicip "github.com/stackitcloud/stackit-cli/internal/cmd/beta/public-ip"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/quota"
securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/beta/security-group"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/server"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex"
@@ -54,4 +55,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
cmd.AddCommand(securitygroup.NewCmd(p))
cmd.AddCommand(keypair.NewCmd(p))
cmd.AddCommand(image.NewCmd(p))
+ cmd.AddCommand(quota.NewCmd(p))
}
diff --git a/internal/cmd/beta/quota/list/list.go b/internal/cmd/beta/quota/list/list.go
new file mode 100644
index 000000000..32a2be9d4
--- /dev/null
+++ b/internal/cmd/beta/quota/list/list.go
@@ -0,0 +1,190 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/goccy/go-yaml"
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/args"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/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"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+}
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists quotas",
+ Long: "Lists project quotas.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List available quotas`,
+ `$ stackit beta quota list`,
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ return err
+ }
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ projectLabel, err := projectname.GetProjectName(ctx, p, cmd)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "get project name: %v", err)
+ projectLabel = model.ProjectId
+ }
+
+ // Call API
+ request := buildRequest(ctx, model, apiClient)
+
+ response, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("list quotas: %w", err)
+ }
+
+ if items := response.Quotas; items == nil {
+ p.Info("No quotas found for project %q", projectLabel)
+ } else {
+ if err := outputResult(p, model.OutputFormat, items); err != nil {
+ return fmt.Errorf("output quotas: %w", err)
+ }
+ }
+
+ return nil
+ },
+ }
+
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ }
+
+ 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.ApiListQuotasRequest {
+ request := apiClient.ListQuotas(ctx, model.ProjectId)
+
+ return request
+}
+
+func outputResult(p *print.Printer, outputFormat string, quotas *iaas.QuotaList) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(quotas, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal quota list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(quotas, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ if err != nil {
+ return fmt.Errorf("marshal quota list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+
+ default:
+ table := tables.NewTable()
+ table.SetHeader("NAME", "LIMIT", "CURRENT USAGE", "PERCENT")
+ if val := quotas.BackupGigabytes; val != nil {
+ table.AddRow("Total size in GiB of backups [GiB]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Backups; val != nil {
+ table.AddRow("Number of backups [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Gigabytes; val != nil {
+ table.AddRow("Total size in GiB of volumes and snapshots [GiB]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Networks; val != nil {
+ table.AddRow("Number of networks [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Nics; val != nil {
+ table.AddRow("Number of network interfaces (nics) [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.PublicIps; val != nil {
+ table.AddRow("Number of public IP addresses [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Ram; val != nil {
+ table.AddRow("Amount of server RAM in MiB [MiB]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.SecurityGroupRules; val != nil {
+ table.AddRow("Number of security group rules [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.SecurityGroups; val != nil {
+ table.AddRow("Number of security groups [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Snapshots; val != nil {
+ table.AddRow("Number of snapshots [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Vcpu; val != nil {
+ table.AddRow("Number of server cores (vcpu) [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ if val := quotas.Volumes; val != nil {
+ table.AddRow("Number of volumes [Count]", conv(val.GetLimit()), conv(val.GetUsage()), percentage(val))
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ }
+}
+
+func conv(n *int64) string {
+ if n != nil {
+ return strconv.FormatInt(*n, 10)
+ }
+ return "n/a"
+}
+
+func percentage(val interface {
+ GetLimit() *int64
+ GetUsage() *int64
+}) string {
+ if a, b := val.GetLimit(), val.GetUsage(); a != nil && b != nil {
+ return fmt.Sprintf("%3.1f%%", 100.0/float64(*a)*float64(*b))
+ }
+ return "n/a"
+}
diff --git a/internal/cmd/beta/quota/list/list_test.go b/internal/cmd/beta/quota/list/list_test.go
new file mode 100644
index 000000000..b8fa5319c
--- /dev/null
+++ b/internal/cmd/beta/quota/list/list_test.go
@@ -0,0 +1,164 @@
+package list
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+)
+
+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},
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListQuotasRequest)) iaas.ApiListQuotasRequest {
+ request := testClient.ListQuotas(testCtx, testProjectId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ if err := globalflags.Configure(cmd.Flags()); err != nil {
+ t.Errorf("cannot configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err := cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ if err := cmd.ValidateRequiredFlags(); err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing flags: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiListQuotasRequest
+ }{
+ {
+ 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/quota/quota.go b/internal/cmd/beta/quota/quota.go
new file mode 100644
index 000000000..e4eaf4bd8
--- /dev/null
+++ b/internal/cmd/beta/quota/quota.go
@@ -0,0 +1,29 @@
+package quota
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/quota/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: "quota",
+ Short: "Manage server quotas",
+ Long: "Manage the lifecycle of server quotas.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, p)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, p *print.Printer) {
+ cmd.AddCommand(
+ list.NewCmd(p),
+ )
+}