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