From 7e5e64e5e02d13fdc40b68c6a1d895814e931006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Fri, 18 Sep 2026 17:20:48 +0100 Subject: [PATCH 01/11] feat(sca): base setup, list and describe applications --- go.mod | 3 +- go.sum | 4 + internal/cmd/config/set/set.go | 4 + internal/cmd/config/unset/unset.go | 7 + internal/cmd/config/unset/unset_test.go | 13 ++ internal/cmd/root.go | 2 + internal/cmd/sca/application/application.go | 27 +++ .../cmd/sca/application/describe/describe.go | 137 +++++++++++++++ internal/cmd/sca/application/list/list.go | 164 ++++++++++++++++++ internal/cmd/sca/sca.go | 26 +++ internal/pkg/config/config.go | 3 + internal/pkg/services/sca/client/client.go | 13 ++ internal/pkg/services/sca/utils/utils.go | 29 ++++ internal/pkg/services/sca/utils/utils_test.go | 51 ++++++ qa.json | 47 +++++ 15 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/sca/application/application.go create mode 100644 internal/cmd/sca/application/describe/describe.go create mode 100644 internal/cmd/sca/application/list/list.go create mode 100644 internal/cmd/sca/sca.go create mode 100644 internal/pkg/services/sca/client/client.go create mode 100644 internal/pkg/services/sca/utils/utils.go create mode 100644 internal/pkg/services/sca/utils/utils_test.go create mode 100644 qa.json diff --git a/go.mod b/go.mod index 916453c27..123088c7c 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 - github.com/stackitcloud/stackit-sdk-go/core v0.26.0 + github.com/stackitcloud/stackit-sdk-go/core v0.27.0 github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.2 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.3 @@ -207,6 +207,7 @@ require ( github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stackitcloud/stackit-sdk-go/services/sca v0.0.0-20260907133539-142366f465a2 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect diff --git a/go.sum b/go.sum index 854729b5f..299b515d9 100644 --- a/go.sum +++ b/go.sum @@ -592,6 +592,8 @@ github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YE github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA= github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= +github.com/stackitcloud/stackit-sdk-go/core v0.27.0 h1:7lc6qStcFDFf8zHP4ORPa9joybw+Sa2LtB6BVjKQ+ek= +github.com/stackitcloud/stackit-sdk-go/core v0.27.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.1 h1:YOMgUO/0/FZPxOmkKIbEIjzpzJ2VFhfPn3ErID9qC40= github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.1/go.mod h1:eK6oRB5Tmpt6KbXQ4UYBGg2LgW5bPtVoncL9E8JSRww= github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.2 h1:zY0ArhTg++8oOAlytCRck3u41ZakAvU74ZkwBuMnipg= @@ -638,6 +640,8 @@ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2 h1:4UxxJ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.9.1 h1:OkBEYR73PRzk4aNP+Ua/40XMza6TiJWju7HhLlINMdU= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.9.1/go.mod h1:iB27HtF0UcAugURc9w+nlNrtbAj7Mukw/ptAz+7p2WE= +github.com/stackitcloud/stackit-sdk-go/services/sca v0.0.0-20260907133539-142366f465a2 h1:kCd6lhQrBMC6/nfW9VAT5005Gq2kIHJGwR1Tsa5Hirc= +github.com/stackitcloud/stackit-sdk-go/services/sca v0.0.0-20260907133539-142366f465a2/go.mod h1:SB1HA3WwYYOBbWjZ/LhZR/SKoY+3qLJFXWNea5S/Vgg= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2 h1:1MvnXjo5C73HPErKY7pWIg7j2QXrH9z/axCVihEIvNo= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2/go.mod h1:2XA8PE05Qg6BL2YXO4XgfGI9qskJ3cicLE5Qq0aqDdY= github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1 h1:lU6kQgv2tWLKDkhBRhS3v9BSFoUwHAcUOkHEa52kPVA= diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 5401e4aa6..7003083b5 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -55,6 +55,7 @@ const ( sfsCustomEndpointFlag = "sfs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + scaCustomEndpointFlag = "sca-custom-endpoint" ) type inputModel struct { @@ -176,6 +177,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(sfsCustomEndpointFlag, "", "SFS API base URL, used in calls to this API") cmd.Flags().String(cdnCustomEndpointFlag, "", "CDN API base URL, used in calls to this API") cmd.Flags().String(vpnCustomEndpointFlag, "", "VPN API base URL, used in calls to this API") + cmd.Flags().String(scaCustomEndpointFlag, "", "SCA API base URL, used in calls to this API") err := viper.BindPFlag(config.SessionTimeLimitKey, cmd.Flags().Lookup(sessionTimeLimitFlag)) cobra.CheckErr(err) @@ -248,6 +250,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.VPNCustomEndpointKey, cmd.Flags().Lookup(vpnCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.SCACustomEndpointKey, cmd.Flags().Lookup(scaCustomEndpointFlag)) + cobra.CheckErr(err) } func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 110204f64..190954e41 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -58,6 +58,7 @@ const ( logsCustomEndpointFlag = "logs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + scaCustomEndpointFlag = "sca-custom-endpoint" ) var ( @@ -109,6 +110,7 @@ type inputModel struct { LogsCustomEndpoint bool CDNCustomEndpoint bool VpnCustomEndpoint bool + ScaCustomEndpoint bool } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -256,6 +258,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.VpnCustomEndpoint { viper.Set(config.VPNCustomEndpointKey, "") } + if model.ScaCustomEndpoint { + viper.Set(config.SCACustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -312,6 +317,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(sfsCustomEndpointFlag, false, "SFS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(cdnCustomEndpointFlag, false, "Custom CDN endpoint URL. If unset, uses the default base URL") cmd.Flags().Bool(vpnCustomEndpointFlag, false, "VPN API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(scaCustomEndpointFlag, false, "SCA API base URL. If unset, uses the default base URL") } func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { @@ -359,6 +365,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { LogsCustomEndpoint: flags.FlagToBoolValue(p, cmd, logsCustomEndpointFlag), CDNCustomEndpoint: flags.FlagToBoolValue(p, cmd, cdnCustomEndpointFlag), VpnCustomEndpoint: flags.FlagToBoolValue(p, cmd, vpnCustomEndpointFlag), + ScaCustomEndpoint: flags.FlagToBoolValue(p, cmd, scaCustomEndpointFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 75965ec31..1ec694021 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -50,6 +50,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool logsCustomEndpointFlag: true, cdnCustomEndpointFlag: true, vpnCustomEndpointFlag: true, + scaCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -98,6 +99,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { LogsCustomEndpoint: true, CDNCustomEndpoint: true, VpnCustomEndpoint: true, + ScaCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -162,6 +164,7 @@ func TestParseInput(t *testing.T) { model.LogsCustomEndpoint = false model.CDNCustomEndpoint = false model.VpnCustomEndpoint = false + model.ScaCustomEndpoint = false }), }, { @@ -384,6 +387,16 @@ func TestParseInput(t *testing.T) { model.VpnCustomEndpoint = false }), }, + { + description: "sca custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[scaCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ScaCustomEndpoint = false + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bbb05aca3..475ec8513 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca" "github.com/stackitcloud/stackit-cli/internal/cmd/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/pkg/types" @@ -207,6 +208,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(kms.NewCmd(params)) cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(valkey.NewCmd(params)) + cmd.AddCommand(sca.NewCmd(params)) } // traverseCommands calls f for c and all of its children. diff --git a/internal/cmd/sca/application/application.go b/internal/cmd/sca/application/application.go new file mode 100644 index 000000000..d1d8ffd72 --- /dev/null +++ b/internal/cmd/sca/application/application.go @@ -0,0 +1,27 @@ +package application + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "application", + Short: "Provides functionality for SCA applications", + Long: "Provides functionality for STACKIT Container Applications (SCA) cluster.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/sca/application/describe/describe.go b/internal/cmd/sca/application/describe/describe.go new file mode 100644 index 000000000..f657d3c50 --- /dev/null +++ b/internal/cmd/sca/application/describe/describe.go @@ -0,0 +1,137 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + scautils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + applicationIDArg = "APPLICATION_ID" + environmentIDFlag = "environment-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + ApplicationID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of a SCA application", + Long: "Show details of a STACKIT Kubernetes Engine (SCA) application.", + Args: args.SingleArg(applicationIDArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application describe xxx --environment-id yyy"), + examples.NewExample( + `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, + "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + globalFlags := globalflags.Parse(params.Printer, cmd) + if globalFlags.ProjectId == "" { + return &errors.ProjectIdError{} + } + + // Call API + req := apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe SCA application: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func outputResult(p *print.Printer, outputFormat string, application *sca.Application) error { + return p.OutputResult(outputFormat, application, func() error { + if application == nil { + p.Outputf("No application found") + return nil + } + + table := tables.NewTable() + table.SetTitle("Application") + table.AddRow("ID", utils.PtrString(application.Id)) + table.AddSeparator() + table.AddRow("NAME", application.DisplayName) + table.AddSeparator() + table.AddRow("STATUS", scautils.ApplicationStatusToStr(application.RuntimeStatus.GetCurrentStatus())) + table.AddSeparator() + table.AddRow("STATE", scautils.ApplicationStateToStr(application.GetStopped())) + + containersTable := tables.NewTable() + containersTable.SetTitle("Application Containers") + containersTable.SetHeader("NAME", "IMAGE", "CPU", "MEMORY") + for _, c := range application.Containers { + containersTable.AddRow( + c.Name, + c.Image, + c.Cpu, + c.Memory, + ) + } + err := tables.DisplayTables(p, []tables.Table{table, containersTable}) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, environmentIDFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + applicationID := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: flags.FlagToStringValue(p, cmd, environmentIDFlag), + ApplicationID: applicationID, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/sca/application/list/list.go b/internal/cmd/sca/application/list/list.go new file mode 100644 index 000000000..8abc54e57 --- /dev/null +++ b/internal/cmd/sca/application/list/list.go @@ -0,0 +1,164 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + scautils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + limitFlag = "limit" + environmentIDFlag = "environment-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all SCA applications", + Long: "Lists all STACKIT Kubernetes Engine (SCA) applications.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all SCA applications`, + "$ stackit sca application list"), + examples.NewExample( + `List all SCA applications from enviroment with ID "xxx"`, + "$ stackit sca application list --environment-id xxx"), + examples.NewExample( + `List all SCA applications in JSON format`, + "$ stackit sca application list --output-format json"), + examples.NewExample( + `List up to 10 SCA applications`, + "$ stackit sca application list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + applications, err := makeRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("list SCA applications: %w", err) + } + + // Truncate output + if model.Limit != nil && len(applications) > int(*model.Limit) { + applications = applications[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(applications) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, applications) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Optional environment ID to filter applications") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +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, + EnvironmentID: flags.FlagToStringValue(p, cmd, environmentIDFlag), + Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func makeRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) ([]sca.ApplicationSummary, error) { + // If environment ID is not defined, return all applications from the project + if model.EnvironmentID == "" { + resp, err := apiClient.DefaultAPI.ListProjectApplications(ctx, model.ProjectId).Execute() + if err != nil { + return nil, err + } + + return resp.Items, err + } + + resp, err := apiClient.DefaultAPI.ListApplications(ctx, model.ProjectId, model.EnvironmentID).Execute() + if err != nil { + return nil, err + } + + return resp.Items, err +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, applications []sca.ApplicationSummary) error { + return p.OutputResult(outputFormat, applications, func() error { + if len(applications) == 0 { + p.Outputf("No applications found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "INSTANCES", "ENVIRONMENT", "ENVIRONMENT ID", "URL", "STATUS", "STATE") + for _, a := range applications { + + table.AddRow( + a.GetId(), + a.GetDisplayName(), + a.GetInstances(), + a.GetEnvironmentName(), + a.GetEnvironmentId(), + a.GetUrl(), + scautils.ApplicationStatusToStr(a.GetStatus()), + scautils.ApplicationStateToStr(a.GetStopped()), + // state, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/sca/sca.go b/internal/cmd/sca/sca.go new file mode 100644 index 000000000..b9a80c71b --- /dev/null +++ b/internal/cmd/sca/sca.go @@ -0,0 +1,26 @@ +package sca + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "sca", + Short: "Provides functionality for SCA", + Long: "Provides functionality for STACKIT Container Applications (SCA).", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(application.NewCmd(params)) +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 2365ed8b4..f166ef86f 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -58,6 +58,7 @@ const ( LogsCustomEndpointKey = "logs_custom_endpoint" ValkeyCustomEndpointKey = "valkey_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" + SCACustomEndpointKey = "sca_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -128,6 +129,7 @@ var ConfigKeys = []string{ TokenCustomEndpointKey, ValkeyCustomEndpointKey, VPNCustomEndpointKey, + SCACustomEndpointKey, } var defaultConfigFolderPath string @@ -222,6 +224,7 @@ func setConfigDefaults() { viper.SetDefault(CDNCustomEndpointKey, "") viper.SetDefault(ValkeyCustomEndpointKey, "") viper.SetDefault(VPNCustomEndpointKey, "") + viper.SetDefault(SCACustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/services/sca/client/client.go b/internal/pkg/services/sca/client/client.go new file mode 100644 index 000000000..78e002d5f --- /dev/null +++ b/internal/pkg/services/sca/client/client.go @@ -0,0 +1,13 @@ +package client + +import ( + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*sca.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.SCACustomEndpointKey), true, sca.NewAPIClient) +} diff --git a/internal/pkg/services/sca/utils/utils.go b/internal/pkg/services/sca/utils/utils.go new file mode 100644 index 000000000..3201e58d0 --- /dev/null +++ b/internal/pkg/services/sca/utils/utils.go @@ -0,0 +1,29 @@ +package utils + +import ( + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +func ApplicationStatusToStr(status sca.CurrentStatus) string { + switch status { + case sca.CURRENTSTATUS_CURRENT_STATUS_RUNNING: + return "Running" + case sca.CURRENTSTATUS_CURRENT_STATUS_IDLE: + return "Idle" + case sca.CURRENTSTATUS_CURRENT_STATUS_FAILED: + return "Failed" + case sca.CURRENTSTATUS_CURRENT_STATUS_PROGRESSING: + return "Progressing" + case sca.CURRENTSTATUS_CURRENT_STATUS_NONE: + return "None" + default: + return "None" + } +} + +func ApplicationStateToStr(isStopped bool) string { + if isStopped == false { + return "Active" + } + return "Stopped" +} diff --git a/internal/pkg/services/sca/utils/utils_test.go b/internal/pkg/services/sca/utils/utils_test.go new file mode 100644 index 000000000..17bded8eb --- /dev/null +++ b/internal/pkg/services/sca/utils/utils_test.go @@ -0,0 +1,51 @@ +package utils_test + +import ( + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +func TestApplicationStatusToStr(t *testing.T) { + tests := []struct { + desc string + status v1alphaapi.CurrentStatus + expected string + }{ + {desc: "Running", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_RUNNING, expected: "Running"}, + {desc: "Progressing", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_PROGRESSING, expected: "Progressing"}, + {desc: "Idle", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_IDLE, expected: "Idle"}, + {desc: "Failed", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_FAILED, expected: "Failed"}, + {desc: "None", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_NONE, expected: "None"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got := utils.ApplicationStatusToStr(tt.status) + if tt.expected != got { + t.Errorf("expected converted status to be %q, got %q", tt.expected, got) + } + }) + } +} + +func TestAppllicationStateToStr(t *testing.T) { + tests := []struct { + desc string + stopped bool + expected string + }{ + {desc: "Not stopped", stopped: false, expected: "Active"}, + {desc: "Stopped", stopped: true, expected: "Stopped"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got := utils.ApplicationStateToStr(tt.stopped) + if tt.expected != got { + t.Errorf("expected state to be %q, got %q", tt.expected, got) + } + }) + } +} diff --git a/qa.json b/qa.json new file mode 100644 index 000000000..50f460b3f --- /dev/null +++ b/qa.json @@ -0,0 +1,47 @@ +{ + "alb_custom _endpoint": "", + "alb_waf_custom_endpoint": "", + "allowed_url_domain": "stackit.cloud", + "assume_yes": false, + "async": false, + "authorization_custom_endpoint": "", + "cdn_custom_endpoint": "", + "dns_custom_endpoint": "", + "edge_custom_endpoint": "", + "git_custom_endpoint": "", + "iaas_custom_endpoint": "", + "identity_provider_custom_client_id": "", + "identity_provider_custom_well_known_configuration": "https://accounts.qa.stackit.cloud/.well-known/openid-configuration", + "intake_custom_endpoint": "", + "kms_custom_endpoint": "", + "load_balancer_custom_endpoint": "", + "logme_custom_endpoint": "", + "logs_custom_endpoint": "", + "mariadb_custom_endpoint": "", + "mongodbflex_custom_endpoint": "", + "object_storage_custom_endpoint": "", + "observability_custom_endpoint": "", + "opensearch_custom_endpoint": "", + "output_format": "", + "postgresflex_custom_endpoint": "", + "project_id": "", + "rabbitmq_custom_endpoint": "", + "redis_custom_endpoint": "", + "region": "eu01", + "resource_manager_custom_endpoint": "", + "runcommand_custom_endpoint": "", + "sca_custom_endpoint": "https://sca.api.eu01.qa.stackit.cloud", + "secrets_manager_custom_endpoint": "", + "serverbackup_custom_endpoint": "", + "serverosupdate_custom_endpoint": "", + "service_account_custom_endpoint": "", + "service_enablement_custom_endpoint": "", + "session_time_limit": "12h", + "sfs_custom_endpoint": "", + "ske_custom_endpoint": "", + "sqlserverflex_custom_endpoint": "", + "token_custom_endpoint": "", + "valkey_custom_endpoint": "", + "verbosity": "info", + "vpn_custom_endpoint": "" +} \ No newline at end of file From 12136220e9c1ed22d9c87bace76bf7ecc9fee6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Tue, 22 Sep 2026 11:00:46 +0100 Subject: [PATCH 02/11] fix(sca): error handling --- internal/cmd/sca/application/describe/describe.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cmd/sca/application/describe/describe.go b/internal/cmd/sca/application/describe/describe.go index f657d3c50..7716f70a4 100644 --- a/internal/cmd/sca/application/describe/describe.go +++ b/internal/cmd/sca/application/describe/describe.go @@ -48,6 +48,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { ctx := context.Background() model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } // Configure API client apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) From 241c7d7b013f6cb457a73b494e9e1b0204096e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Tue, 22 Sep 2026 11:24:49 +0100 Subject: [PATCH 03/11] feat(sca): delete command --- internal/cmd/sca/application/application.go | 4 + internal/cmd/sca/application/delete/delete.go | 101 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 internal/cmd/sca/application/delete/delete.go diff --git a/internal/cmd/sca/application/application.go b/internal/cmd/sca/application/application.go index d1d8ffd72..80c87d33b 100644 --- a/internal/cmd/sca/application/application.go +++ b/internal/cmd/sca/application/application.go @@ -2,6 +2,8 @@ package application import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -24,4 +26,6 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) } diff --git a/internal/cmd/sca/application/delete/delete.go b/internal/cmd/sca/application/delete/delete.go new file mode 100644 index 000000000..531d22601 --- /dev/null +++ b/internal/cmd/sca/application/delete/delete.go @@ -0,0 +1,101 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/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/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + applicationIDArg = "APPLICATION_ID" + environmentIDFlag = "environment-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + ApplicationID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a SCA application", + Long: "Delete a STACKIT Kubernetes Engine (SCA) application.", + Args: args.SingleArg(applicationIDArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application delete xxx --environment-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + globalFlags := globalflags.Parse(params.Printer, cmd) + if globalFlags.ProjectId == "" { + return &errors.ProjectIdError{} + } + + // Call API + req := apiClient.DefaultAPI.DeleteApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("delete application: %w", err) + } + + params.Printer.Info("Deleted application %q from environment %q\n", resp.GetId(), resp.GetEnvironmentId()) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + applicationID := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) + if environmentID == "" { + environmentID = globalFlags.ProjectId + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + ApplicationID: applicationID, + } + + p.DebugInputModel(model) + return &model, nil +} From 90fe33a0fc8868bfc4a62344e12ca820bb42c7ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Tue, 22 Sep 2026 11:25:04 +0100 Subject: [PATCH 04/11] wip(sca): create command --- internal/cmd/sca/application/create/create.go | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 internal/cmd/sca/application/create/create.go diff --git a/internal/cmd/sca/application/create/create.go b/internal/cmd/sca/application/create/create.go new file mode 100644 index 000000000..3d6704924 --- /dev/null +++ b/internal/cmd/sca/application/create/create.go @@ -0,0 +1,264 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" +) + +const ( + environmentIDFlag = "environment-id" + nameFlag = "name" + imageFlag = "image" + publicFlag = "public" + externalPortFlag = "external-port" + cpuFlag = "cpu" + memoryFlag = "memory" + instancesFlag = "instances" + minInstancesFlag = "min-instances" + maxInstancesFlag = "max-instances" + scaleToZeroFlag = "scale-to-zero" +) + +var scalingTypeFlag = flags.StringEnumFlag( + "scaling-type", + []string{"manual", "auto"}, + "Scaling type,", + flags.StringEnumDefaultValue("manual"), +) + +const ( + defaultPublic = true + defaultPort = 8080 + defaultCPU = 1000 + defaultMemory = 1024 + defaultScalingType = "manual" + defaultInstances = 1 + // defaultMinInstances = 1 + // defaultMaxInstances = 1 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + Name string + Image string + ScalingType string + Public bool + ContainerExternalPort int32 + CPU int32 + Memory int32 + Instances int32 + MinInstances int32 + MaxInstances int32 + ScaleToZero bool + Concurrency int32 + RPS int32 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a SCA application", + Long: "Create a STACKIT Kubernetes Engine (SCA) application.", + Args: args.NoArgs, + Example: examples.Build( + // TODO: fix examples + examples.NewExample( + `Create a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application describe xxx --environment-id yyy"), + examples.NewExample( + `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, + "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + globalFlags := globalflags.Parse(params.Printer, cmd) + if globalFlags.ProjectId == "" { + return &errors.ProjectIdError{} + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create application: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, fmt.Sprintf("Creating application with id %q", resp.GetId()), func() error { + _, err := wait.CreateApplicationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for application creation: %w", err) + } + } + + outputResult(params.Printer, model, resp) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func buildScalingRules(model *inputModel) []sca.ScaleRule { + scaleRules := []sca.ScaleRule{} + if model.Concurrency != 0 || model.RPS != 0 { + scaleRules = append(scaleRules, sca.ScaleRule{ + Type: sca.RULETYPE_RULE_TYPE_HTTP, + HttpRule: &sca.HttpScaleRule{ + Concurrency: &model.Concurrency, + Rps: &model.RPS, + }, + }) + } + + return scaleRules +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiCreateApplicationRequest { + scaling := sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: model.Instances, + }, + } + if model.ScalingType == "auto" { + rules := []sca.ScaleRule{ + { + HttpRule: &sca.HttpScaleRule{ + Concurrency: model.Concurrency, + Rps: model.RPS, + }, + }, + } + + scaling = sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_AUTO, + AutoScaling: &sca.AutoScaling{ + MinInstances: model.MinInstances, + MaxInstances: model.MaxInstances, + AllowScaleToZero: &model.ScaleToZero, + Rules: rules, + }, + } + + } + + network := sca.Network{ + PublicIngress: model.Public, + Port: &model.ContainerExternalPort, + } + + container := sca.Container{ + Name: "container-1", + Image: model.Image, + Cpu: &model.CPU, + Memory: &model.Memory, + } + + payload := sca.CreateApplicationPayload{ + DisplayName: model.Name, + Scaling: scaling, + Network: network, + Containers: []sca.Container{container}, + } + + return apiClient.DefaultAPI.CreateApplication(ctx, model.ProjectId, model.EnvironmentID). + CreateApplicationPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") + cmd.Flags().String(nameFlag, "", "Application display name") + cmd.Flags().String(imageFlag, "", "Container image") + cmd.Flags().Bool(publicFlag, defaultPublic, "Exposes your application securely to the public internet via HTTPS endpoint") + cmd.Flags().Int32(externalPortFlag, defaultPort, "Container external exposed port") + cmd.Flags().Int32(cpuFlag, defaultCPU, "The dedicated virtual CPU processing power allocated per container instance") + cmd.Flags().Int32(memoryFlag, defaultMemory, "The total amount of memory (RAM) allocated per container instance") + scalingTypeFlag.Register(cmd.Flags()) + // cmd.Flags().String(scalingTypeFlag, defaultScalingType, "") + cmd.Flags().Int32(instancesFlag, defaultInstances, "") + cmd.Flags().Int32(minInstancesFlag, 0, "") + cmd.Flags().Int32(maxInstancesFlag, 0, "") + cmd.Flags().Bool(maxInstancesFlag, false, "") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, nameFlag)) + cobra.CheckErr(flags.MarkFlagsRequired(cmd, imageFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + extertalPort := flags.FlagWithDefaultToInt32Value(p, cmd, externalPortFlag) + if extertalPort <= 1024 || extertalPort > 65535 { + return nil, &errors.FlagValidationError{ + Flag: externalPortFlag, + Details: "must be a valid non-privileged port (from 1025 to 65535)", + } + } + + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) + if environmentID == "" { + environmentID = globalFlags.ProjectId + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + Image: flags.FlagToStringValue(p, cmd, imageFlag), + Public: flags.FlagToBoolValue(p, cmd, publicFlag), + ContainerExternalPort: extertalPort, + CPU: flags.FlagWithDefaultToInt32Value(p, cmd, cpuFlag), + Memory: flags.FlagWithDefaultToInt32Value(p, cmd, memoryFlag), + Instances: flags.FlagWithDefaultToInt32Value(p, cmd, instancesFlag), + ScalingType: flags.FlagToStringValue(p, cmd, scalingTypeFlag), + MinInstances: flags.FlagWithDefaultToInt32Value(p, cmd, minInstancesFlag), + MaxInstances: flags.FlagWithDefaultToInt32Value(p, cmd, maxInstancesFlag), + ScaleToZero: flags.FlagToBoolValue(p, cmd, scaleToZeroFlag), + } + + p.DebugInputModel(model) + return &model, nil +} From 532ba63f8ba1f83598d01d4f731444d615bd3e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Tue, 22 Sep 2026 11:26:46 +0100 Subject: [PATCH 05/11] chore(sca): sca package version v0.1.0 --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 123088c7c..f1011cac6 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.13.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.9.1 + github.com/stackitcloud/stackit-sdk-go/services/sca v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2 github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1 github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.5.4 @@ -207,7 +208,6 @@ require ( github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stackitcloud/stackit-sdk-go/services/sca v0.0.0-20260907133539-142366f465a2 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect diff --git a/go.sum b/go.sum index 299b515d9..dd777cf17 100644 --- a/go.sum +++ b/go.sum @@ -590,8 +590,6 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA= -github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= github.com/stackitcloud/stackit-sdk-go/core v0.27.0 h1:7lc6qStcFDFf8zHP4ORPa9joybw+Sa2LtB6BVjKQ+ek= github.com/stackitcloud/stackit-sdk-go/core v0.27.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.1 h1:YOMgUO/0/FZPxOmkKIbEIjzpzJ2VFhfPn3ErID9qC40= @@ -640,8 +638,8 @@ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2 h1:4UxxJ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.9.1 h1:OkBEYR73PRzk4aNP+Ua/40XMza6TiJWju7HhLlINMdU= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.9.1/go.mod h1:iB27HtF0UcAugURc9w+nlNrtbAj7Mukw/ptAz+7p2WE= -github.com/stackitcloud/stackit-sdk-go/services/sca v0.0.0-20260907133539-142366f465a2 h1:kCd6lhQrBMC6/nfW9VAT5005Gq2kIHJGwR1Tsa5Hirc= -github.com/stackitcloud/stackit-sdk-go/services/sca v0.0.0-20260907133539-142366f465a2/go.mod h1:SB1HA3WwYYOBbWjZ/LhZR/SKoY+3qLJFXWNea5S/Vgg= +github.com/stackitcloud/stackit-sdk-go/services/sca v0.1.0 h1:P8ubDswgf9cXmIpRWIhaHePZYak7UIYTFF3eL4Qw9v8= +github.com/stackitcloud/stackit-sdk-go/services/sca v0.1.0/go.mod h1:SB1HA3WwYYOBbWjZ/LhZR/SKoY+3qLJFXWNea5S/Vgg= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2 h1:1MvnXjo5C73HPErKY7pWIg7j2QXrH9z/axCVihEIvNo= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2/go.mod h1:2XA8PE05Qg6BL2YXO4XgfGI9qskJ3cicLE5Qq0aqDdY= github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1 h1:lU6kQgv2tWLKDkhBRhS3v9BSFoUwHAcUOkHEa52kPVA= From a738569b521afce0e56c829f83b50cb834509121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Tue, 29 Sep 2026 09:25:25 +0100 Subject: [PATCH 06/11] feat(sca): missing funcionality and testing --- internal/cmd/sca/application/application.go | 8 + .../create_from_payload.go | 148 ++++++++++ internal/cmd/sca/application/create/create.go | 178 +++++++----- .../cmd/sca/application/create/create_test.go | 263 ++++++++++++++++++ internal/cmd/sca/application/delete/delete.go | 7 +- .../cmd/sca/application/delete/delete_test.go | 175 ++++++++++++ .../cmd/sca/application/describe/describe.go | 23 +- .../sca/application/describe/describe_test.go | 158 +++++++++++ .../generate-payload/generate_payload.go | 140 ++++++++++ .../generate-payload/generate_payload_test.go | 169 +++++++++++ internal/cmd/sca/application/list/list.go | 29 +- .../cmd/sca/application/list/list_test.go | 144 ++++++++++ .../update-from-payload.go | 151 ++++++++++ internal/cmd/sca/application/update/update.go | 198 +++++++++++++ internal/pkg/services/sca/utils/utils.go | 23 ++ 15 files changed, 1728 insertions(+), 86 deletions(-) create mode 100644 internal/cmd/sca/application/create-from-payload/create_from_payload.go create mode 100644 internal/cmd/sca/application/create/create_test.go create mode 100644 internal/cmd/sca/application/delete/delete_test.go create mode 100644 internal/cmd/sca/application/describe/describe_test.go create mode 100644 internal/cmd/sca/application/generate-payload/generate_payload.go create mode 100644 internal/cmd/sca/application/generate-payload/generate_payload_test.go create mode 100644 internal/cmd/sca/application/list/list_test.go create mode 100644 internal/cmd/sca/application/update-from-payload/update-from-payload.go create mode 100644 internal/cmd/sca/application/update/update.go diff --git a/internal/cmd/sca/application/application.go b/internal/cmd/sca/application/application.go index 80c87d33b..02527478f 100644 --- a/internal/cmd/sca/application/application.go +++ b/internal/cmd/sca/application/application.go @@ -3,9 +3,13 @@ package application import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create" + createfrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create-from-payload" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/generate-payload" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/update" + updatefrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/update-from-payload" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -27,5 +31,9 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(createfrompayload.NewCmd(params)) cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(generatepayload.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(updatefrompayload.NewCmd(params)) } diff --git a/internal/cmd/sca/application/create-from-payload/create_from_payload.go b/internal/cmd/sca/application/create-from-payload/create_from_payload.go new file mode 100644 index 000000000..503eec0ec --- /dev/null +++ b/internal/cmd/sca/application/create-from-payload/create_from_payload.go @@ -0,0 +1,148 @@ +package createfrompayload + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" +) + +const ( + environmentIDFlag = "environment-id" + nameFlag = "name" + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + Payload *sca.CreateApplicationPayload +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create-from-payload", + Short: "Create a SCA application from payload", + Long: "Create a STACKIT Kubernetes Engine (SCA) application from payload.", + Args: args.NoArgs, + Example: examples.Build( + // TODO: fix examples + examples.NewExample( + `Create a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application describe xxx --environment-id yyy"), + examples.NewExample( + `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, + "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + globalFlags := globalflags.Parse(params.Printer, cmd) + if globalFlags.ProjectId == "" { + return &errors.ProjectIdError{} + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create application: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, fmt.Sprintf("Creating application with id %q", resp.GetId()), func() error { + _, err := wait.CreateApplicationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for application creation: %w", err) + } + } + + outputResult(params.Printer, model, resp) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiCreateApplicationRequest { + return apiClient.DefaultAPI.CreateApplication(ctx, model.ProjectId, model.EnvironmentID). + CreateApplicationPayload(*model.Payload) +} + +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") + cmd.Flags().String(nameFlag, "", "Application display name") + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit sca application generate-payload")`) + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, nameFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) + if environmentID == "" { + environmentID = globalFlags.ProjectId + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *sca.CreateApplicationPayload + if payloadValue != nil { + payload = &sca.CreateApplicationPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("enconde payload: %w", err) + } + } + + payload.DisplayName = flags.FlagToStringValue(p, cmd, nameFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/sca/application/create/create.go b/internal/cmd/sca/application/create/create.go index 3d6704924..4137c4f92 100644 --- a/internal/cmd/sca/application/create/create.go +++ b/internal/cmd/sca/application/create/create.go @@ -30,24 +30,28 @@ const ( minInstancesFlag = "min-instances" maxInstancesFlag = "max-instances" scaleToZeroFlag = "scale-to-zero" + envVarsFlag = "environment-vars" + commandsFlag = "commands" + argsFlag = "args" +) + +const ( + scalingTypeManual = "manual" + scalingTypeAuto = "auto" + + defaultPublic = true + defaultPort = 8080 + defaultCPU = 1000 + defaultMemory = 1024 + defaultInstances = 1 + defaultContainerName = "container-1" ) var scalingTypeFlag = flags.StringEnumFlag( "scaling-type", - []string{"manual", "auto"}, + []string{scalingTypeManual, scalingTypeAuto}, "Scaling type,", - flags.StringEnumDefaultValue("manual"), -) - -const ( - defaultPublic = true - defaultPort = 8080 - defaultCPU = 1000 - defaultMemory = 1024 - defaultScalingType = "manual" - defaultInstances = 1 - // defaultMinInstances = 1 - // defaultMaxInstances = 1 + flags.StringEnumDefaultValue(scalingTypeManual), ) type inputModel struct { @@ -66,6 +70,9 @@ type inputModel struct { ScaleToZero bool Concurrency int32 RPS int32 + EnvironmentVars map[string]string + Commands []string + Args []string } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -75,18 +82,26 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Long: "Create a STACKIT Kubernetes Engine (SCA) application.", Args: args.NoArgs, Example: examples.Build( - // TODO: fix examples examples.NewExample( - `Create a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application describe xxx --environment-id yyy"), + `Create a SCA application with name "application-name" and image "my-image" for an environment with ID "yyy"`, + "$ stackit sca application create --name application-name --image my-image --environment-id yyy"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" with 2 instances`, + "$ stackit sca application create --name application-name --image my-image --instances 2"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" exposing port 8888 of the container`, + "$ stackit sca application create --name application-name --image my-image --port 8888"), examples.NewExample( - `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, - "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + `Create a SCA application with name "application-name" and image "my-image" disabling public networking`, + "$ stackit sca application create --name application-name --image my-image --public=false"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" and environment variables ENV1=value1 and ENV2=value2`, + "$ stackit sca application create --name application-name --image my-image --environment-vars ENV1=value1,ENV2=value2"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -129,8 +144,29 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return cmd } +func buildScalingConfig(model *inputModel) sca.Scaling { + if model.ScalingType == scalingTypeAuto { + return sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_AUTO, + AutoScaling: &sca.AutoScaling{ + MinInstances: model.MinInstances, + MaxInstances: model.MaxInstances, + AllowScaleToZero: &model.ScaleToZero, + Rules: buildScalingRules(model), + }, + } + } + + return sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: model.Instances, + }, + } +} + func buildScalingRules(model *inputModel) []sca.ScaleRule { - scaleRules := []sca.ScaleRule{} + var scaleRules []sca.ScaleRule if model.Concurrency != 0 || model.RPS != 0 { scaleRules = append(scaleRules, sca.ScaleRule{ Type: sca.RULETYPE_RULE_TYPE_HTTP, @@ -145,49 +181,36 @@ func buildScalingRules(model *inputModel) []sca.ScaleRule { } func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiCreateApplicationRequest { - scaling := sca.Scaling{ - Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, - ManualScaling: &sca.ManualScaling{ - Instances: model.Instances, - }, - } - if model.ScalingType == "auto" { - rules := []sca.ScaleRule{ - { - HttpRule: &sca.HttpScaleRule{ - Concurrency: model.Concurrency, - Rps: model.RPS, - }, - }, - } - - scaling = sca.Scaling{ - Type: sca.SCALINGTYPE_SCALING_TYPE_AUTO, - AutoScaling: &sca.AutoScaling{ - MinInstances: model.MinInstances, - MaxInstances: model.MaxInstances, - AllowScaleToZero: &model.ScaleToZero, - Rules: rules, - }, - } - - } - network := sca.Network{ PublicIngress: model.Public, Port: &model.ContainerExternalPort, } + var envVars []sca.EnvVar + if model.EnvironmentVars != nil { + envVars = make([]sca.EnvVar, 0, len(model.EnvironmentVars)) + for k, v := range model.EnvironmentVars { + envVars = append(envVars, sca.EnvVar{ + Key: k, + Value: v, + Origin: sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr(), + }) + } + } + container := sca.Container{ - Name: "container-1", - Image: model.Image, - Cpu: &model.CPU, - Memory: &model.Memory, + Name: defaultContainerName, + Image: model.Image, + Cpu: &model.CPU, + Memory: &model.Memory, + EnvironmentVariables: envVars, + Command: model.Commands, + Args: model.Args, } payload := sca.CreateApplicationPayload{ DisplayName: model.Name, - Scaling: scaling, + Scaling: buildScalingConfig(model), Network: network, Containers: []sca.Container{container}, } @@ -214,22 +237,29 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int32(cpuFlag, defaultCPU, "The dedicated virtual CPU processing power allocated per container instance") cmd.Flags().Int32(memoryFlag, defaultMemory, "The total amount of memory (RAM) allocated per container instance") scalingTypeFlag.Register(cmd.Flags()) - // cmd.Flags().String(scalingTypeFlag, defaultScalingType, "") - cmd.Flags().Int32(instancesFlag, defaultInstances, "") - cmd.Flags().Int32(minInstancesFlag, 0, "") - cmd.Flags().Int32(maxInstancesFlag, 0, "") - cmd.Flags().Bool(maxInstancesFlag, false, "") + cmd.Flags().Int32(instancesFlag, defaultInstances, "The number of application instances (if manually scaled)") + cmd.Flags().Int32(minInstancesFlag, 0, "The minimum number of application instances (if autoscaling is enabled)") + cmd.Flags().Int32(maxInstancesFlag, 0, "The maximum number of application instances (if autoscaling is enabled)") + cmd.Flags().Bool(scaleToZeroFlag, false, "Enable scale to zero (if autoscaling is enabled)") + cmd.Flags().StringToString(envVarsFlag, nil, "Environment variables to inject into the application") + cmd.Flags().StringSlice(commandsFlag, nil, "Commands to execute in the application container") + cmd.Flags().StringSlice(argsFlag, nil, "Arguments to pass to the application container command") cobra.CheckErr(flags.MarkFlagsRequired(cmd, nameFlag)) cobra.CheckErr(flags.MarkFlagsRequired(cmd, imageFlag)) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} } + var scalingType string + if scalingTypeFlagValue := scalingTypeFlag.Ptr(); scalingTypeFlagValue != nil && *scalingTypeFlagValue != "" { + scalingType = *scalingTypeFlagValue + } + extertalPort := flags.FlagWithDefaultToInt32Value(p, cmd, externalPortFlag) if extertalPort <= 1024 || extertalPort > 65535 { return nil, &errors.FlagValidationError{ @@ -238,6 +268,27 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } } + instances := flags.FlagWithDefaultToInt32Value(p, cmd, instancesFlag) + if instances < 0 || instances > 10 { + return nil, &errors.FlagValidationError{ + Flag: instancesFlag, + Details: "must be an integer between 0 and 10", + } + } + + cpu := flags.FlagWithDefaultToInt32Value(p, cmd, cpuFlag) + if cpu%1000 != 0 { + return nil, &errors.FlagValidationError{ + Flag: instancesFlag, + Details: "must be divisible by 1000", + } + } + + var envVars map[string]string + if env := flags.FlagToStringToStringPointer(p, cmd, envVarsFlag); env != nil { + envVars = *env + } + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) if environmentID == "" { environmentID = globalFlags.ProjectId @@ -250,13 +301,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Image: flags.FlagToStringValue(p, cmd, imageFlag), Public: flags.FlagToBoolValue(p, cmd, publicFlag), ContainerExternalPort: extertalPort, - CPU: flags.FlagWithDefaultToInt32Value(p, cmd, cpuFlag), + CPU: cpu, Memory: flags.FlagWithDefaultToInt32Value(p, cmd, memoryFlag), - Instances: flags.FlagWithDefaultToInt32Value(p, cmd, instancesFlag), - ScalingType: flags.FlagToStringValue(p, cmd, scalingTypeFlag), + Instances: instances, + ScalingType: scalingType, MinInstances: flags.FlagWithDefaultToInt32Value(p, cmd, minInstancesFlag), MaxInstances: flags.FlagWithDefaultToInt32Value(p, cmd, maxInstancesFlag), ScaleToZero: flags.FlagToBoolValue(p, cmd, scaleToZeroFlag), + EnvironmentVars: envVars, + Commands: flags.FlagToStringSliceValue(p, cmd, commandsFlag), + Args: flags.FlagToStringSliceValue(p, cmd, argsFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/sca/application/create/create_test.go b/internal/cmd/sca/application/create/create_test.go new file mode 100644 index 000000000..8d38035f9 --- /dev/null +++ b/internal/cmd/sca/application/create/create_test.go @@ -0,0 +1,263 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + + environmentIDFlag: testEnvironmentID, + nameFlag: "test-application-name", + imageFlag: "test-image", + externalPortFlag: "8888", + cpuFlag: "2000", + memoryFlag: "2048", + instancesFlag: "2", + publicFlag: "true", + scalingTypeFlag.Name(): scalingTypeManual, + // minInstancesFlag: "", + // maxInstancesFlag: "", + // scaleToZeroFlag: "", + envVarsFlag: "ENV1=val1,ENV2=val2", + commandsFlag: "/bin/sh,-c", + argsFlag: "echo 'test'", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + EnvironmentID: testEnvironmentID, + Name: "test-application-name", + Image: "test-image", + ContainerExternalPort: 8888, + ScalingType: scalingTypeManual, + Public: true, + CPU: 2000, + Memory: 2048, + Instances: 2, + EnvironmentVars: map[string]string{ + "ENV1": "val1", + "ENV2": "val2", + }, + Commands: []string{"/bin/sh", "-c"}, + Args: []string{"echo 'test'"}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sca.ApiCreateApplicationRequest)) sca.ApiCreateApplicationRequest { + request := testClient.DefaultAPI.CreateApplication(testCtx, testProjectID, testEnvironmentID) + request = request.CreateApplicationPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *sca.CreateApplicationPayload)) sca.CreateApplicationPayload { + payload := sca.CreateApplicationPayload{ + DisplayName: "test-application-name", + Network: sca.Network{ + PublicIngress: true, + Port: utils.Ptr(int32(8888)), + }, + Scaling: sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{}, + }, + Containers: []sca.Container{{ + Name: "container-1", + Image: "test-image", + Cpu: utils.Ptr(int32(2000)), + Memory: utils.Ptr(int32(2048)), + Command: []string{"/bin/sh", "-c"}, + Args: []string{"echo 'test'"}, + EnvironmentVariables: []sca.EnvVar{ + {Key: "ENV1", Value: "val1", Origin: sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr()}, + {Key: "ENV2", Value: "val2", Origin: sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr()}, + }, + }}, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "required only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, environmentIDFlag) + delete(flagValues, externalPortFlag) + delete(flagValues, cpuFlag) + delete(flagValues, memoryFlag) + delete(flagValues, instancesFlag) + delete(flagValues, publicFlag) + delete(flagValues, scalingTypeFlag.String()) + delete(flagValues, envVarsFlag) + delete(flagValues, commandsFlag) + delete(flagValues, argsFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.CPU = 1000 + model.Memory = 1024 + model.ScalingType = scalingTypeManual + model.Instances = 1 + model.ContainerExternalPort = 8080 + model.Public = true + model.EnvironmentID = testProjectID + model.EnvironmentVars = nil + model.Commands = nil + model.Args = nil + }), + isValid: true, + }, + { + desc: "missing name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + desc: "missing image", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, imageFlag) + }), + isValid: false, + }, + { + desc: "no values", + flagValues: nil, + isValid: false, + }, + { + desc: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "invalid port", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[externalPortFlag] = "80" + }), + isValid: false, + }, + { + desc: "invalid scaling type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = "invalid-scaling" + }), + isValid: false, + }, + { + desc: "invalid instances", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instancesFlag] = "-2" + }), + isValid: false, + }, + { + desc: "invalid cpu", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[cpuFlag] = "1500" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sca.ApiCreateApplicationRequest + }{ + { + 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, sca.DefaultAPIService{}), + cmpopts.SortSlices(func(a, b sca.EnvVar) bool { + return a.Key < b.Key + }), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/delete/delete.go b/internal/cmd/sca/application/delete/delete.go index 531d22601..233a260bd 100644 --- a/internal/cmd/sca/application/delete/delete.go +++ b/internal/cmd/sca/application/delete/delete.go @@ -13,6 +13,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" "github.com/stackitcloud/stackit-cli/internal/pkg/types" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( @@ -57,7 +58,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - req := apiClient.DefaultAPI.DeleteApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) + req := buildRequest(ctx, model, apiClient) resp, err := req.Execute() if err != nil { return fmt.Errorf("delete application: %w", err) @@ -99,3 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu p.DebugInputModel(model) return &model, nil } + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiDeleteApplicationRequest { + return apiClient.DefaultAPI.DeleteApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) +} diff --git a/internal/cmd/sca/application/delete/delete_test.go b/internal/cmd/sca/application/delete/delete_test.go new file mode 100644 index 000000000..0828edbeb --- /dev/null +++ b/internal/cmd/sca/application/delete/delete_test.go @@ -0,0 +1,175 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() + testApplicationID = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testApplicationID, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + + environmentIDFlag: testEnvironmentID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + EnvironmentID: testEnvironmentID, + ApplicationID: testApplicationID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sca.ApiDeleteApplicationRequest)) sca.ApiDeleteApplicationRequest { + request := testClient.DefaultAPI.DeleteApplication(testCtx, testProjectID, testEnvironmentID, testApplicationID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + argValues []string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "required only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, environmentIDFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.EnvironmentID = testProjectID + }), + isValid: true, + }, + { + desc: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + desc: "no values", + argValues: nil, + flagValues: nil, + isValid: false, + }, + { + desc: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "environment id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[environmentIDFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sca.ApiDeleteApplicationRequest + }{ + { + 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, sca.DefaultAPIService{}), + cmpopts.SortSlices(func(a, b sca.EnvVar) bool { + return a.Key < b.Key + }), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/describe/describe.go b/internal/cmd/sca/application/describe/describe.go index 7716f70a4..f6eb2596b 100644 --- a/internal/cmd/sca/application/describe/describe.go +++ b/internal/cmd/sca/application/describe/describe.go @@ -64,7 +64,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - req := apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) + req := buildRequest(ctx, model, apiClient) resp, err := req.Execute() if err != nil { return fmt.Errorf("describe SCA application: %w", err) @@ -94,6 +94,8 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic table.AddRow("STATUS", scautils.ApplicationStatusToStr(application.RuntimeStatus.GetCurrentStatus())) table.AddSeparator() table.AddRow("STATE", scautils.ApplicationStateToStr(application.GetStopped())) + table.AddSeparator() + table.AddRow("INSTANCES", len(application.RuntimeStatus.Instances)) containersTable := tables.NewTable() containersTable.SetTitle("Application Containers") @@ -102,8 +104,8 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic containersTable.AddRow( c.Name, c.Image, - c.Cpu, - c.Memory, + *c.Cpu, + *c.Memory, ) } err := tables.DisplayTables(p, []tables.Table{table, containersTable}) @@ -116,9 +118,7 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic } func configureFlags(cmd *cobra.Command) { - cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID") - - cobra.CheckErr(flags.MarkFlagsRequired(cmd, environmentIDFlag)) + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (if not set uses default environment)") } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -129,12 +129,21 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu return nil, &errors.ProjectIdError{} } + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) + if environmentID == "" { + environmentID = globalFlags.ProjectId + } + model := inputModel{ GlobalFlagModel: globalFlags, - EnvironmentID: flags.FlagToStringValue(p, cmd, environmentIDFlag), + EnvironmentID: environmentID, ApplicationID: applicationID, } p.DebugInputModel(model) return &model, nil } + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiGetApplicationRequest { + return apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) +} diff --git a/internal/cmd/sca/application/describe/describe_test.go b/internal/cmd/sca/application/describe/describe_test.go new file mode 100644 index 000000000..61604c395 --- /dev/null +++ b/internal/cmd/sca/application/describe/describe_test.go @@ -0,0 +1,158 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() + testApplicationID = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testApplicationID, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + + environmentIDFlag: testEnvironmentID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + EnvironmentID: testEnvironmentID, + ApplicationID: testApplicationID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sca.ApiGetApplicationRequest)) sca.ApiGetApplicationRequest { + request := testClient.DefaultAPI.GetApplication(testCtx, testProjectID, testEnvironmentID, testApplicationID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + argValues []string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "no values", + argValues: nil, + flagValues: nil, + isValid: false, + }, + { + desc: "application id missing", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + desc: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "environment id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[environmentIDFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sca.ApiGetApplicationRequest + }{ + { + 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, sca.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/generate-payload/generate_payload.go b/internal/cmd/sca/application/generate-payload/generate_payload.go new file mode 100644 index 000000000..b5828de8b --- /dev/null +++ b/internal/cmd/sca/application/generate-payload/generate_payload.go @@ -0,0 +1,140 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" + "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/sca/client" + scautils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + applicationIDFlag = "application-id" + environmentIDFlag = "environment-id" + filePathFlag = "file-path" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ApplicationID *string + EnvironmentID *string + FilePath *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to create/updaet SCA applications", + Long: fmt.Sprintf("%s\n%s", + "Generates a JSON payload with values to be used as --payload input for application creation or update.", + "See https://docs.api.stackit.cloud/documentation/sca/version/v1alpha#tag/Applications/operation/Applications_CreateApplication for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + ``, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + var payload *sca.CreateApplicationPayload + if model.ApplicationID == nil { + payload = scautils.GetDefaultPayload() + } else { + req := apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, *model.EnvironmentID, *model.ApplicationID) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read application: %w", err) + } + + payload = &sca.CreateApplicationPayload{ + Secrets: resp.Secrets, + Registry: resp.Registry, + Scaling: resp.Scaling, + Network: resp.Network, + Containers: resp.Containers, + } + + } + + return outputResult(params.Printer, model.FilePath, payload) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), applicationIDFlag, "") + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "") + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + applicationID := flags.FlagToStringPointer(p, cmd, applicationIDFlag) + if applicationID != nil && globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + environmentID := flags.FlagToStringPointer(p, cmd, environmentIDFlag) + if environmentID == nil && globalFlags.ProjectId != "" { + environmentID = &globalFlags.ProjectId + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ApplicationID: applicationID, + EnvironmentID: environmentID, + FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiGetApplicationRequest { + return apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, *model.EnvironmentID, *model.ApplicationID) +} + +func outputResult(p *print.Printer, filePath *string, payload *sca.CreateApplicationPayload) error { + if payload == nil { + return fmt.Errorf("application payload is empty") + } + payloadBytes, err := json.MarshalIndent(*payload, "", " ") + if err != nil { + return fmt.Errorf("marshal create application payload: %w", err) + } + + if filePath != nil { + err = fileutils.WriteToFile(utils.PtrString(filePath), string(payloadBytes)) + if err != nil { + return fmt.Errorf("write create application payload to the file: %w", err) + } + } else { + p.Outputln(string(payloadBytes)) + } + + return nil +} diff --git a/internal/cmd/sca/application/generate-payload/generate_payload_test.go b/internal/cmd/sca/application/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..67576f725 --- /dev/null +++ b/internal/cmd/sca/application/generate-payload/generate_payload_test.go @@ -0,0 +1,169 @@ +package generatepayload + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() + testApplicationID = uuid.NewString() + testFilePath = "example-file" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + + environmentIDFlag: testEnvironmentID, + applicationIDFlag: testApplicationID, + filePathFlag: testFilePath, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ApplicationID: &testApplicationID, + EnvironmentID: &testEnvironmentID, + FilePath: &testFilePath, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sca.ApiGetApplicationRequest)) sca.ApiGetApplicationRequest { + request := testClient.DefaultAPI.GetApplication(testCtx, testProjectID, testEnvironmentID, testApplicationID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + argValues []string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "no values", + flagValues: map[string]string{}, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.GlobalFlagModel.ProjectId = "" + model.GlobalFlagModel.Region = "" + model.GlobalFlagModel.Verbosity = globalflags.VerbosityDefault + model.EnvironmentID = nil + model.ApplicationID = nil + model.FilePath = nil + }), + isValid: true, + }, + { + desc: "application id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, applicationIDFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ApplicationID = nil + }), + isValid: true, + }, + { + desc: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "environment id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[environmentIDFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "application id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[applicationIDFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sca.ApiGetApplicationRequest + }{ + { + 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, sca.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/list/list.go b/internal/cmd/sca/application/list/list.go index 8abc54e57..5fce07750 100644 --- a/internal/cmd/sca/application/list/list.go +++ b/internal/cmd/sca/application/list/list.go @@ -24,6 +24,10 @@ const ( environmentIDFlag = "environment-id" ) +type listApplicationsRequest interface { + Execute() (*sca.ListApplicationsResponse, error) +} + type inputModel struct { *globalflags.GlobalFlagModel EnvironmentID string @@ -53,7 +57,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, nil) if err != nil { return err } @@ -65,11 +69,14 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - applications, err := makeRequest(ctx, model, apiClient) + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() if err != nil { return fmt.Errorf("list SCA applications: %w", err) } + applications := resp.Items + // Truncate output if model.Limit != nil && len(applications) > int(*model.Limit) { applications = applications[:*model.Limit] @@ -96,7 +103,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,23 +119,13 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return &model, nil } -func makeRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) ([]sca.ApplicationSummary, error) { +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) listApplicationsRequest { // If environment ID is not defined, return all applications from the project if model.EnvironmentID == "" { - resp, err := apiClient.DefaultAPI.ListProjectApplications(ctx, model.ProjectId).Execute() - if err != nil { - return nil, err - } - - return resp.Items, err - } - - resp, err := apiClient.DefaultAPI.ListApplications(ctx, model.ProjectId, model.EnvironmentID).Execute() - if err != nil { - return nil, err + return apiClient.DefaultAPI.ListProjectApplications(ctx, model.ProjectId) } - return resp.Items, err + return apiClient.DefaultAPI.ListApplications(ctx, model.ProjectId, model.EnvironmentID) } func outputResult(p *print.Printer, outputFormat, projectLabel string, applications []sca.ApplicationSummary) error { diff --git a/internal/cmd/sca/application/list/list_test.go b/internal/cmd/sca/application/list/list_test.go new file mode 100644 index 000000000..f9fdfc270 --- /dev/null +++ b/internal/cmd/sca/application/list/list_test.go @@ -0,0 +1,144 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + + environmentIDFlag: testEnvironmentID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + EnvironmentID: testEnvironmentID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request listApplicationsRequest)) listApplicationsRequest { + request := testClient.DefaultAPI.ListApplications(testCtx, testProjectID, testEnvironmentID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "no values", + flagValues: nil, + isValid: false, + }, + { + desc: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "environment id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[environmentIDFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest listApplicationsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no environment", + model: fixtureInputModel(func(model *inputModel) { + model.EnvironmentID = "" + }), + expectedRequest: testClient.DefaultAPI.ListProjectApplications(testCtx, testProjectID), + }, + } + + 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, sca.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/update-from-payload/update-from-payload.go b/internal/cmd/sca/application/update-from-payload/update-from-payload.go new file mode 100644 index 000000000..0f0fe1947 --- /dev/null +++ b/internal/cmd/sca/application/update-from-payload/update-from-payload.go @@ -0,0 +1,151 @@ +package updatefrompayload + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" +) + +const ( + applicationIDArg = "APPLICATION_ID" + environmentIDFlag = "environment-id" + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + ApplicationID string + Payload *sca.UpdateApplicationPayload +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update-from-payload", + Short: "Update a SCA application from payload", + Long: "Update a STACKIT Kubernetes Engine (SCA) application from payload.", + Args: args.SingleArg(applicationIDArg, nil), + Example: examples.Build( + // TODO: fix examples + examples.NewExample( + `Update a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application describe xxx --environment-id yyy"), + examples.NewExample( + `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, + "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + globalFlags := globalflags.Parse(params.Printer, cmd) + if globalFlags.ProjectId == "" { + return &errors.ProjectIdError{} + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update application: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, fmt.Sprintf("Updating application with id %q", resp.GetId()), func() error { + _, err := wait.UpdateApplicationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for application update: %w", err) + } + } + + outputResult(params.Printer, model, resp) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiUpdateApplicationRequest { + return apiClient.DefaultAPI.UpdateApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID). + UpdateApplicationPayload(*model.Payload) +} + +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit sca application generate-payload")`) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + applicationID := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) + if environmentID == "" { + environmentID = globalFlags.ProjectId + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *sca.UpdateApplicationPayload + if payloadValue != nil { + payload = &sca.UpdateApplicationPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("enconde payload: %w", err) + } + } + + payload.AdditionalProperties = nil + + fmt.Printf("%+v\n", payload) + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + ApplicationID: applicationID, + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/sca/application/update/update.go b/internal/cmd/sca/application/update/update.go new file mode 100644 index 000000000..c0466b30d --- /dev/null +++ b/internal/cmd/sca/application/update/update.go @@ -0,0 +1,198 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" +) + +const ( + applicationIDArg = "APPLICATION_ID" + environmentIDFlag = "environment-id" + nameFlag = "name" + imageFlag = "image" + publicFlag = "public" + externalPortFlag = "external-port" + cpuFlag = "cpu" + memoryFlag = "memory" + instancesFlag = "instances" + minInstancesFlag = "min-instances" + maxInstancesFlag = "max-instances" + scaleToZeroFlag = "scale-to-zero" + stoppedFlag = "stopped" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + ApplicationID string + Image string + ScalingType string + Public *bool + ContainerExternalPort int32 + CPU int32 + Memory int32 + Instances int32 + MinInstances int32 + MaxInstances int32 + ScaleToZero bool + Concurrency int32 + RPS int32 + Stopped *bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update a SCA application", + Long: "Update a STACKIT Kubernetes Engine (SCA) application.", + Args: args.SingleArg(applicationIDArg, nil), + Example: examples.Build( + // TODO: fix examples + examples.NewExample( + `Update a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application describe xxx --environment-id yyy"), + examples.NewExample( + `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, + "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + globalFlags := globalflags.Parse(params.Printer, cmd) + if globalFlags.ProjectId == "" { + return &errors.ProjectIdError{} + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update application: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, fmt.Sprintf("Updating application with id %q", resp.GetId()), func() error { + _, err := wait.UpdateApplicationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for application update: %w", err) + } + } + + outputResult(params.Printer, model, resp) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func containersFromInput(model *inputModel) []sca.Container { + container := sca.Container{} + updateContainer := false + if model.Image != "" { + updateContainer = true + container.Image = model.Image + } + if model.CPU != 0 { + updateContainer = true + container.Cpu = &model.CPU + } + if model.Memory != 0 { + updateContainer = true + container.Memory = &model.Memory + } + + if updateContainer { + return []sca.Container{container} + } + + return nil +} + +func networkFromInput(model *inputModel) *sca.Network { + if model.Public == nil || model.ContainerExternalPort == 0 { + return nil + } + + return &sca.Network{ + PublicIngress: *model.Public, + Port: &model.ContainerExternalPort, + } +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiUpdateApplicationRequest { + payload := sca.UpdateApplicationPayload{ + Stopped: model.Stopped, + } + payload.Containers = containersFromInput(model) + payload.Network = networkFromInput(model) + + return apiClient.DefaultAPI.UpdateApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID). + UpdateApplicationPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") + cmd.Flags().Bool(stoppedFlag, false, "Stopped") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + applicationID := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + environmentID := flags.FlagToStringValue(p, cmd, environmentIDFlag) + if environmentID == "" { + environmentID = globalFlags.ProjectId + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + ApplicationID: applicationID, + Stopped: flags.FlagToBoolPointer(p, cmd, stoppedFlag), + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/pkg/services/sca/utils/utils.go b/internal/pkg/services/sca/utils/utils.go index 3201e58d0..78534ab05 100644 --- a/internal/pkg/services/sca/utils/utils.go +++ b/internal/pkg/services/sca/utils/utils.go @@ -4,6 +4,29 @@ import ( sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) +func GetDefaultPayload() *sca.CreateApplicationPayload { + return &sca.CreateApplicationPayload{ + Containers: []sca.Container{ + { + Name: "container-1", + Image: "", + Cpu: sca.PtrInt32(1000), + Memory: sca.PtrInt32(1024), + }, + }, + Scaling: sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: 1, + }, + }, + Network: sca.Network{ + PublicIngress: true, + Port: sca.PtrInt32(8080), + }, + } +} + func ApplicationStatusToStr(status sca.CurrentStatus) string { switch status { case sca.CURRENTSTATUS_CURRENT_STATUS_RUNNING: From 29f696ef8592177ffef8ea44b12dad035f62eca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Tue, 29 Sep 2026 12:37:48 +0100 Subject: [PATCH 07/11] fix(sca): linter --- internal/cmd/sca/application/application.go | 1 + .../create_from_payload.go | 11 +- .../create_from_payload_test.go | 188 ++++++++++++++++ internal/cmd/sca/application/create/create.go | 5 +- .../cmd/sca/application/create/create_test.go | 3 +- internal/cmd/sca/application/delete/delete.go | 3 +- .../cmd/sca/application/delete/delete_test.go | 3 +- .../cmd/sca/application/describe/describe.go | 3 +- .../sca/application/describe/describe_test.go | 3 +- .../generate-payload/generate_payload.go | 19 +- .../generate-payload/generate_payload_test.go | 9 +- internal/cmd/sca/application/list/list.go | 9 +- .../cmd/sca/application/list/list_test.go | 3 +- .../update-from-payload.go | 19 +- .../update_from_payload_test.go | 207 ++++++++++++++++++ internal/cmd/sca/application/update/update.go | 5 +- internal/pkg/services/sca/client/client.go | 3 +- internal/pkg/services/sca/utils/utils.go | 2 +- internal/pkg/services/sca/utils/utils_test.go | 3 +- 19 files changed, 462 insertions(+), 37 deletions(-) create mode 100644 internal/cmd/sca/application/create-from-payload/create_from_payload_test.go create mode 100644 internal/cmd/sca/application/update-from-payload/update_from_payload_test.go diff --git a/internal/cmd/sca/application/application.go b/internal/cmd/sca/application/application.go index 02527478f..d53d40e8a 100644 --- a/internal/cmd/sca/application/application.go +++ b/internal/cmd/sca/application/application.go @@ -2,6 +2,7 @@ package application import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create" createfrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create-from-payload" "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/delete" diff --git a/internal/cmd/sca/application/create-from-payload/create_from_payload.go b/internal/cmd/sca/application/create-from-payload/create_from_payload.go index 503eec0ec..ecdc51670 100644 --- a/internal/cmd/sca/application/create-from-payload/create_from_payload.go +++ b/internal/cmd/sca/application/create-from-payload/create_from_payload.go @@ -6,6 +6,9 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,8 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" - "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" ) const ( @@ -46,10 +47,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, "$ stackit sca application describe xxx --environment-id yyy --output-format json"), ), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, nil) if err != nil { return err } @@ -114,7 +115,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(flags.MarkFlagsRequired(cmd, nameFlag)) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} diff --git a/internal/cmd/sca/application/create-from-payload/create_from_payload_test.go b/internal/cmd/sca/application/create-from-payload/create_from_payload_test.go new file mode 100644 index 000000000..226a8e61f --- /dev/null +++ b/internal/cmd/sca/application/create-from-payload/create_from_payload_test.go @@ -0,0 +1,188 @@ +package createfrompayload + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() + + testPayload = &sca.CreateApplicationPayload{ + DisplayName: "test-application", + AdditionalProperties: map[string]any{}, + Containers: []sca.Container{{ + Name: "test-container", + Image: "test-image", + Cpu: sca.PtrInt32(2000), + Memory: sca.PtrInt32(2048), + AdditionalProperties: map[string]any{}, + }}, + Scaling: sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: 2, + AdditionalProperties: map[string]any{}, + }, + AdditionalProperties: map[string]any{}, + }, + Network: sca.Network{ + PublicIngress: true, + Port: sca.PtrInt32(8888), + AdditionalProperties: map[string]any{}, + }, + } +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + + environmentIDFlag: testEnvironmentID, + nameFlag: "test-application", + payloadFlag: `{ + "displayName": "", + "containers": [{ + "name": "test-container", + "image": "test-image", + "cpu": 2000, + "memory": 2048 + }], + "scaling": { + "type": "SCALING_TYPE_MANUAL", + "manualScaling": { + "instances": 2 + } + }, + "network": { + "publicIngress": true, + "port": 8888 + } + }`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + EnvironmentID: testEnvironmentID, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sca.ApiCreateApplicationRequest)) sca.ApiCreateApplicationRequest { + request := testClient.DefaultAPI.CreateApplication(testCtx, testProjectID, testEnvironmentID) + request = request.CreateApplicationPayload(*testPayload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "no values", + flagValues: nil, + isValid: false, + }, + { + desc: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sca.ApiCreateApplicationRequest + }{ + { + 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, sca.DefaultAPIService{}), + cmpopts.SortSlices(func(a, b sca.EnvVar) bool { + return a.Key < b.Key + }), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/create/create.go b/internal/cmd/sca/application/create/create.go index 4137c4f92..b4f2ed0da 100644 --- a/internal/cmd/sca/application/create/create.go +++ b/internal/cmd/sca/application/create/create.go @@ -5,6 +5,9 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" - "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" ) const ( diff --git a/internal/cmd/sca/application/create/create_test.go b/internal/cmd/sca/application/create/create_test.go index 8d38035f9..8dcd4d16e 100644 --- a/internal/cmd/sca/application/create/create_test.go +++ b/internal/cmd/sca/application/create/create_test.go @@ -7,10 +7,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( diff --git a/internal/cmd/sca/application/delete/delete.go b/internal/cmd/sca/application/delete/delete.go index 233a260bd..b947ad04d 100644 --- a/internal/cmd/sca/application/delete/delete.go +++ b/internal/cmd/sca/application/delete/delete.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +15,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" "github.com/stackitcloud/stackit-cli/internal/pkg/types" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( diff --git a/internal/cmd/sca/application/delete/delete_test.go b/internal/cmd/sca/application/delete/delete_test.go index 0828edbeb..fac2244f1 100644 --- a/internal/cmd/sca/application/delete/delete_test.go +++ b/internal/cmd/sca/application/delete/delete_test.go @@ -7,9 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( diff --git a/internal/cmd/sca/application/describe/describe.go b/internal/cmd/sca/application/describe/describe.go index f6eb2596b..aa2c1578f 100644 --- a/internal/cmd/sca/application/describe/describe.go +++ b/internal/cmd/sca/application/describe/describe.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( diff --git a/internal/cmd/sca/application/describe/describe_test.go b/internal/cmd/sca/application/describe/describe_test.go index 61604c395..4b6d867a3 100644 --- a/internal/cmd/sca/application/describe/describe_test.go +++ b/internal/cmd/sca/application/describe/describe_test.go @@ -7,9 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( diff --git a/internal/cmd/sca/application/generate-payload/generate_payload.go b/internal/cmd/sca/application/generate-payload/generate_payload.go index b5828de8b..98684c994 100644 --- a/internal/cmd/sca/application/generate-payload/generate_payload.go +++ b/internal/cmd/sca/application/generate-payload/generate_payload.go @@ -6,6 +6,8 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( scautils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( @@ -44,7 +45,20 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - ``, + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit sca application generate-payload --file-path ./payload.json`, + ``, + `$ stackit sca application create-from-payload --name application-name --payload @./payload.json`, + ), + examples.NewExample( + `Generate a payload with values of an application, and adapt it with custom values for the different configuration options`, + `$ stackit sca application generate-payload --application-id xxx --file-path ./payload.json`, + ``, + `$ stackit sca application update-from-payload --payload @./payload.json`, + ), + examples.NewExample( + `Generate a payload with values of an application, and preview it in the terminal`, + `$ stackit sca application generate-payload --application-id xxx`, ), ), RunE: func(cmd *cobra.Command, args []string) error { @@ -76,7 +90,6 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Network: resp.Network, Containers: resp.Containers, } - } return outputResult(params.Printer, model.FilePath, payload) diff --git a/internal/cmd/sca/application/generate-payload/generate_payload_test.go b/internal/cmd/sca/application/generate-payload/generate_payload_test.go index 67576f725..21436dc0e 100644 --- a/internal/cmd/sca/application/generate-payload/generate_payload_test.go +++ b/internal/cmd/sca/application/generate-payload/generate_payload_test.go @@ -7,9 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( @@ -84,9 +85,9 @@ func TestParseInput(t *testing.T) { desc: "no values", flagValues: map[string]string{}, expectedModel: fixtureInputModel(func(model *inputModel) { - model.GlobalFlagModel.ProjectId = "" - model.GlobalFlagModel.Region = "" - model.GlobalFlagModel.Verbosity = globalflags.VerbosityDefault + model.ProjectId = "" + model.Region = "" + model.Verbosity = globalflags.VerbosityDefault model.EnvironmentID = nil model.ApplicationID = nil model.FilePath = nil diff --git a/internal/cmd/sca/application/list/list.go b/internal/cmd/sca/application/list/list.go index 5fce07750..a7ee91291 100644 --- a/internal/cmd/sca/application/list/list.go +++ b/internal/cmd/sca/application/list/list.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( scautils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/types" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( @@ -45,7 +46,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { `List all SCA applications`, "$ stackit sca application list"), examples.NewExample( - `List all SCA applications from enviroment with ID "xxx"`, + `List all SCA applications from environment with ID "xxx"`, "$ stackit sca application list --environment-id xxx"), examples.NewExample( `List all SCA applications in JSON format`, @@ -54,7 +55,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { `List up to 10 SCA applications`, "$ stackit sca application list --limit 10"), ), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() model, err := parseInput(params.Printer, cmd, nil) @@ -138,7 +139,6 @@ func outputResult(p *print.Printer, outputFormat, projectLabel string, applicati table := tables.NewTable() table.SetHeader("ID", "NAME", "INSTANCES", "ENVIRONMENT", "ENVIRONMENT ID", "URL", "STATUS", "STATE") for _, a := range applications { - table.AddRow( a.GetId(), a.GetDisplayName(), @@ -148,7 +148,6 @@ func outputResult(p *print.Printer, outputFormat, projectLabel string, applicati a.GetUrl(), scautils.ApplicationStatusToStr(a.GetStatus()), scautils.ApplicationStateToStr(a.GetStopped()), - // state, ) } err := table.Display(p) diff --git a/internal/cmd/sca/application/list/list_test.go b/internal/cmd/sca/application/list/list_test.go index f9fdfc270..ecf15e095 100644 --- a/internal/cmd/sca/application/list/list_test.go +++ b/internal/cmd/sca/application/list/list_test.go @@ -7,9 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) const ( diff --git a/internal/cmd/sca/application/update-from-payload/update-from-payload.go b/internal/cmd/sca/application/update-from-payload/update-from-payload.go index 0f0fe1947..991012322 100644 --- a/internal/cmd/sca/application/update-from-payload/update-from-payload.go +++ b/internal/cmd/sca/application/update-from-payload/update-from-payload.go @@ -6,6 +6,9 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,8 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" - "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" ) const ( @@ -39,13 +40,17 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Long: "Update a STACKIT Kubernetes Engine (SCA) application from payload.", Args: args.SingleArg(applicationIDArg, nil), Example: examples.Build( - // TODO: fix examples examples.NewExample( - `Update a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application describe xxx --environment-id yyy"), + `Update a SCA application using an API payload sourced from the file "./payload.json"`, + "$ stackit sca application update-from-payload my-application-id --payload @./payload.json"), + examples.NewExample( + `Update a SCA application using an API payload provided as a JSON string`, + `$ stackit sca application update-from-payload my-application-id --payload "{...}"`), examples.NewExample( - `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, - "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + `Generate a payload with the current values of an application, and adapt it with custom values for the different configuration options`, + `$ stackit sca application generate-payload --application-id application-id > ./payload.json`, + ``, + `$ stackit sca application update-from-payload application-id --payload @./payload.json`), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/update-from-payload/update_from_payload_test.go b/internal/cmd/sca/application/update-from-payload/update_from_payload_test.go new file mode 100644 index 000000000..c2298b92f --- /dev/null +++ b/internal/cmd/sca/application/update-from-payload/update_from_payload_test.go @@ -0,0 +1,207 @@ +package updatefrompayload + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} +var testCtx = context.Background() + +var ( + testProjectID = uuid.NewString() + testEnvironmentID = uuid.NewString() + testApplicationID = uuid.NewString() + + testPayload = &sca.UpdateApplicationPayload{ + Containers: []sca.Container{{ + Name: "test-container", + Image: "test-image", + Cpu: sca.PtrInt32(2000), + Memory: sca.PtrInt32(2048), + AdditionalProperties: map[string]any{}, + }}, + Scaling: &sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: 2, + AdditionalProperties: map[string]any{}, + }, + AdditionalProperties: map[string]any{}, + }, + Network: &sca.Network{ + PublicIngress: true, + Port: sca.PtrInt32(8888), + AdditionalProperties: map[string]any{}, + }, + } +) + +func fixtureArgsValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testApplicationID, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + globalflags.RegionFlag: testRegion, + environmentIDFlag: testEnvironmentID, + payloadFlag: `{ + "containers": [{ + "name": "test-container", + "image": "test-image", + "cpu": 2000, + "memory": 2048 + }], + "scaling": { + "type": "SCALING_TYPE_MANUAL", + "manualScaling": { + "instances": 2 + } + }, + "network": { + "publicIngress": true, + "port": 8888 + } + }`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ApplicationID: testApplicationID, + EnvironmentID: testEnvironmentID, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sca.ApiUpdateApplicationRequest)) sca.ApiUpdateApplicationRequest { + request := testClient.DefaultAPI.UpdateApplication(testCtx, testProjectID, testEnvironmentID, testApplicationID) + request = request.UpdateApplicationPayload(*testPayload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + desc string + argsValues []string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + desc: "base", + argsValues: fixtureArgsValues(), + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + desc: "no values", + argsValues: nil, + flagValues: nil, + isValid: false, + }, + { + desc: "application id missing", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + desc: "project id missing", + argsValues: fixtureArgsValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + desc: "project id invalid", + argsValues: fixtureArgsValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + desc: "invalid json", + argsValues: fixtureArgsValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sca.ApiUpdateApplicationRequest + }{ + { + 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, sca.DefaultAPIService{}), + cmpopts.SortSlices(func(a, b sca.EnvVar) bool { + return a.Key < b.Key + }), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/sca/application/update/update.go b/internal/cmd/sca/application/update/update.go index c0466b30d..b553d3e52 100644 --- a/internal/cmd/sca/application/update/update.go +++ b/internal/cmd/sca/application/update/update.go @@ -5,6 +5,9 @@ import ( "fmt" "github.com/spf13/cobra" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" - "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi/wait" ) const ( diff --git a/internal/pkg/services/sca/client/client.go b/internal/pkg/services/sca/client/client.go index 78e002d5f..be2d3c510 100644 --- a/internal/pkg/services/sca/client/client.go +++ b/internal/pkg/services/sca/client/client.go @@ -2,10 +2,11 @@ package client import ( "github.com/spf13/viper" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) func ConfigureClient(p *print.Printer, cliVersion string) (*sca.APIClient, error) { diff --git a/internal/pkg/services/sca/utils/utils.go b/internal/pkg/services/sca/utils/utils.go index 78534ab05..c2ef8e95b 100644 --- a/internal/pkg/services/sca/utils/utils.go +++ b/internal/pkg/services/sca/utils/utils.go @@ -45,7 +45,7 @@ func ApplicationStatusToStr(status sca.CurrentStatus) string { } func ApplicationStateToStr(isStopped bool) string { - if isStopped == false { + if !isStopped { return "Active" } return "Stopped" diff --git a/internal/pkg/services/sca/utils/utils_test.go b/internal/pkg/services/sca/utils/utils_test.go index 17bded8eb..b99a9afd6 100644 --- a/internal/pkg/services/sca/utils/utils_test.go +++ b/internal/pkg/services/sca/utils/utils_test.go @@ -3,8 +3,9 @@ package utils_test import ( "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" ) func TestApplicationStatusToStr(t *testing.T) { From d4cf417fa5d380009850f79083825e98760d7b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Sun, 4 Oct 2026 19:09:50 +0100 Subject: [PATCH 08/11] fix(sca): big refactor --- internal/cmd/sca/application/create/create.go | 160 +++++--- .../cmd/sca/application/create/create_test.go | 7 +- .../cmd/sca/application/describe/describe.go | 124 ++++++- internal/cmd/sca/application/update/update.go | 349 ++++++++++++++++-- internal/pkg/services/sca/utils/utils.go | 26 ++ 5 files changed, 573 insertions(+), 93 deletions(-) diff --git a/internal/cmd/sca/application/create/create.go b/internal/cmd/sca/application/create/create.go index b4f2ed0da..98e91f8d6 100644 --- a/internal/cmd/sca/application/create/create.go +++ b/internal/cmd/sca/application/create/create.go @@ -15,6 +15,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" ) @@ -22,18 +23,23 @@ import ( const ( environmentIDFlag = "environment-id" nameFlag = "name" - imageFlag = "image" - publicFlag = "public" - externalPortFlag = "external-port" - cpuFlag = "cpu" - memoryFlag = "memory" - instancesFlag = "instances" - minInstancesFlag = "min-instances" - maxInstancesFlag = "max-instances" - scaleToZeroFlag = "scale-to-zero" - envVarsFlag = "environment-vars" - commandsFlag = "commands" - argsFlag = "args" + // Container config + imageFlag = "image" + cpuFlag = "cpu" + memoryFlag = "memory" + envVarsFlag = "environment-vars" + commandsFlag = "commands" + argsFlag = "args" + // Scaling + instancesFlag = "instances" + minInstancesFlag = "min-instances" + maxInstancesFlag = "max-instances" + scaleToZeroFlag = "scale-to-zero" + rpsFlag = "http-rule-rps" + concurrencyFlag = "http-rule-concurrency" + // Networking + publicFlag = "public" + externalPortFlag = "external-port" ) const ( @@ -60,20 +66,20 @@ type inputModel struct { EnvironmentID string Name string Image string - ScalingType string - Public bool - ContainerExternalPort int32 CPU int32 Memory int32 + EnvironmentVars map[string]string + Commands []string + Args []string + ScalingType string Instances int32 MinInstances int32 MaxInstances int32 ScaleToZero bool Concurrency int32 RPS int32 - EnvironmentVars map[string]string - Commands []string - Args []string + Public bool + ContainerExternalPort int32 } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -171,6 +177,7 @@ func buildScalingRules(model *inputModel) []sca.ScaleRule { if model.Concurrency != 0 || model.RPS != 0 { scaleRules = append(scaleRules, sca.ScaleRule{ Type: sca.RULETYPE_RULE_TYPE_HTTP, + Name: "http-scaling-rule", HttpRule: &sca.HttpScaleRule{ Concurrency: &model.Concurrency, Rps: &model.RPS, @@ -187,17 +194,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClie Port: &model.ContainerExternalPort, } - var envVars []sca.EnvVar - if model.EnvironmentVars != nil { - envVars = make([]sca.EnvVar, 0, len(model.EnvironmentVars)) - for k, v := range model.EnvironmentVars { - envVars = append(envVars, sca.EnvVar{ - Key: k, - Value: v, - Origin: sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr(), - }) - } - } + envVars := utils.EnvironmentVariablesFromMap(model.EnvironmentVars) container := sca.Container{ Name: defaultContainerName, @@ -232,19 +229,24 @@ func outputResult(p *print.Printer, model *inputModel, application *sca.Applicat func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") cmd.Flags().String(nameFlag, "", "Application display name") + // container cmd.Flags().String(imageFlag, "", "Container image") - cmd.Flags().Bool(publicFlag, defaultPublic, "Exposes your application securely to the public internet via HTTPS endpoint") - cmd.Flags().Int32(externalPortFlag, defaultPort, "Container external exposed port") cmd.Flags().Int32(cpuFlag, defaultCPU, "The dedicated virtual CPU processing power allocated per container instance") cmd.Flags().Int32(memoryFlag, defaultMemory, "The total amount of memory (RAM) allocated per container instance") - scalingTypeFlag.Register(cmd.Flags()) - cmd.Flags().Int32(instancesFlag, defaultInstances, "The number of application instances (if manually scaled)") - cmd.Flags().Int32(minInstancesFlag, 0, "The minimum number of application instances (if autoscaling is enabled)") - cmd.Flags().Int32(maxInstancesFlag, 0, "The maximum number of application instances (if autoscaling is enabled)") - cmd.Flags().Bool(scaleToZeroFlag, false, "Enable scale to zero (if autoscaling is enabled)") cmd.Flags().StringToString(envVarsFlag, nil, "Environment variables to inject into the application") cmd.Flags().StringSlice(commandsFlag, nil, "Commands to execute in the application container") cmd.Flags().StringSlice(argsFlag, nil, "Arguments to pass to the application container command") + // scaling + scalingTypeFlag.Register(cmd.Flags()) + cmd.Flags().Int32(instancesFlag, defaultInstances, "The number of application instances (if manually scaled)") + cmd.Flags().Int32(minInstancesFlag, 1, "The minimum number of application instances (if autoscaling is enabled)") + cmd.Flags().Int32(maxInstancesFlag, 1, "The maximum number of application instances (if autoscaling is enabled)") + cmd.Flags().Int32(rpsFlag, 0, "Target number of requests per second to trigger autoscaling (if autoscaling is enabled)") + cmd.Flags().Int32(concurrencyFlag, 0, "Target number of in-flight requests to trigger autoscaling (if autoscaling is enabled)") + cmd.Flags().Bool(scaleToZeroFlag, false, "Enable scale to zero (if autoscaling is enabled)") + // networking + cmd.Flags().Bool(publicFlag, defaultPublic, "Exposes your application securely to the public internet via HTTPS endpoint") + cmd.Flags().Int32(externalPortFlag, defaultPort, "Container external exposed port") cobra.CheckErr(flags.MarkFlagsRequired(cmd, nameFlag)) cobra.CheckErr(flags.MarkFlagsRequired(cmd, imageFlag)) @@ -261,12 +263,8 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, scalingType = *scalingTypeFlagValue } - extertalPort := flags.FlagWithDefaultToInt32Value(p, cmd, externalPortFlag) - if extertalPort <= 1024 || extertalPort > 65535 { - return nil, &errors.FlagValidationError{ - Flag: externalPortFlag, - Details: "must be a valid non-privileged port (from 1025 to 65535)", - } + if err := validateScalingInput(cmd, scalingType); err != nil { + return nil, err } instances := flags.FlagWithDefaultToInt32Value(p, cmd, instancesFlag) @@ -277,6 +275,33 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, } } + minInstances := flags.FlagWithDefaultToInt32Value(p, cmd, minInstancesFlag) + if minInstances < 0 || minInstances > 10 { + return nil, &errors.FlagValidationError{ + Flag: minInstancesFlag, + Details: "must be an integer between 0 and 10", + } + } + + maxInstances := flags.FlagWithDefaultToInt32Value(p, cmd, maxInstancesFlag) + if maxInstances == 0 { + maxInstances = minInstances + } + if maxInstances < minInstances || minInstances > 10 { + return nil, &errors.FlagValidationError{ + Flag: minInstancesFlag, + Details: fmt.Sprintf("must be an integer between minInstances (%d) and 10", minInstances), + } + } + + extertalPort := flags.FlagWithDefaultToInt32Value(p, cmd, externalPortFlag) + if extertalPort <= 1024 || extertalPort > 65535 { + return nil, &errors.FlagValidationError{ + Flag: externalPortFlag, + Details: "must be a valid non-privileged port (from 1025 to 65535)", + } + } + cpu := flags.FlagWithDefaultToInt32Value(p, cmd, cpuFlag) if cpu%1000 != 0 { return nil, &errors.FlagValidationError{ @@ -300,20 +325,57 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, EnvironmentID: environmentID, Name: flags.FlagToStringValue(p, cmd, nameFlag), Image: flags.FlagToStringValue(p, cmd, imageFlag), - Public: flags.FlagToBoolValue(p, cmd, publicFlag), - ContainerExternalPort: extertalPort, CPU: cpu, Memory: flags.FlagWithDefaultToInt32Value(p, cmd, memoryFlag), - Instances: instances, - ScalingType: scalingType, - MinInstances: flags.FlagWithDefaultToInt32Value(p, cmd, minInstancesFlag), - MaxInstances: flags.FlagWithDefaultToInt32Value(p, cmd, maxInstancesFlag), - ScaleToZero: flags.FlagToBoolValue(p, cmd, scaleToZeroFlag), EnvironmentVars: envVars, Commands: flags.FlagToStringSliceValue(p, cmd, commandsFlag), Args: flags.FlagToStringSliceValue(p, cmd, argsFlag), + ScalingType: scalingType, + Instances: instances, + MinInstances: minInstances, + MaxInstances: maxInstances, + ScaleToZero: flags.FlagToBoolValue(p, cmd, scaleToZeroFlag), + RPS: flags.FlagWithDefaultToInt32Value(p, cmd, rpsFlag), + Concurrency: flags.FlagWithDefaultToInt32Value(p, cmd, concurrencyFlag), + Public: flags.FlagToBoolValue(p, cmd, publicFlag), + ContainerExternalPort: extertalPort, } p.DebugInputModel(model) return &model, nil } + +func validateScalingInput(cmd *cobra.Command, scalingType string) error { + switch scalingType { + case scalingTypeManual: + autoFlags := []string{minInstancesFlag, maxInstancesFlag, scaleToZeroFlag, concurrencyFlag, rpsFlag} + for _, flagName := range autoFlags { + if cmd.Flags().Changed(flagName) { + return &errors.FlagValidationError{ + Flag: flagName, + Details: fmt.Sprintf("is only valid when --scaling-type is %q", scalingTypeAuto), + } + } + } + case scalingTypeAuto: + if cmd.Flags().Changed(instancesFlag) { + return &errors.FlagValidationError{ + Flag: instancesFlag, + Details: fmt.Sprintf("is only valid when --scaling-type is %q", scalingTypeManual), + } + } + if !cmd.Flags().Changed(rpsFlag) && !cmd.Flags().Changed(concurrencyFlag) { + return &errors.OneOfFlagsIsMissing{ + MissingFlags: []string{rpsFlag, concurrencyFlag}, + SetFlag: fmt.Sprintf("--%s=%s", scalingTypeFlag.Name(), scalingTypeAuto), + } + } + default: + return &errors.FlagValidationError{ + Flag: scalingType, + Details: "invalid scaling type", + } + } + + return nil +} diff --git a/internal/cmd/sca/application/create/create_test.go b/internal/cmd/sca/application/create/create_test.go index 8dcd4d16e..807a32549 100644 --- a/internal/cmd/sca/application/create/create_test.go +++ b/internal/cmd/sca/application/create/create_test.go @@ -11,7 +11,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) const ( @@ -96,7 +95,7 @@ func fixturePayload(mods ...func(payload *sca.CreateApplicationPayload)) sca.Cre DisplayName: "test-application-name", Network: sca.Network{ PublicIngress: true, - Port: utils.Ptr(int32(8888)), + Port: new(int32(8888)), }, Scaling: sca.Scaling{ Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, @@ -105,8 +104,8 @@ func fixturePayload(mods ...func(payload *sca.CreateApplicationPayload)) sca.Cre Containers: []sca.Container{{ Name: "container-1", Image: "test-image", - Cpu: utils.Ptr(int32(2000)), - Memory: utils.Ptr(int32(2048)), + Cpu: new(int32(2000)), + Memory: new(int32(2048)), Command: []string{"/bin/sh", "-c"}, Args: []string{"echo 'test'"}, EnvironmentVariables: []sca.EnvVar{ diff --git a/internal/cmd/sca/application/describe/describe.go b/internal/cmd/sca/application/describe/describe.go index aa2c1578f..61ab5a491 100644 --- a/internal/cmd/sca/application/describe/describe.go +++ b/internal/cmd/sca/application/describe/describe.go @@ -3,6 +3,7 @@ package describe import ( "context" "fmt" + "strings" "github.com/spf13/cobra" sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" @@ -96,20 +97,63 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic table.AddSeparator() table.AddRow("STATE", scautils.ApplicationStateToStr(application.GetStopped())) table.AddSeparator() - table.AddRow("INSTANCES", len(application.RuntimeStatus.Instances)) + table.AddRow("AVAILABE INSTANCES", len(application.RuntimeStatus.Instances)) + table.AddSeparator() + table.AddRow("PUBLIC INGRESS", application.Network.PublicIngress) + if application.Network.Port != nil { + table.AddRow("PUBLIC PORT", *application.Network.Port) + } + if len(application.RuntimeStatus.Urls) > 0 { + table.AddRow("PUBLIC URL", application.RuntimeStatus.Urls[0]) + } + table.AddSeparator() + + tablesToDisplay := []tables.Table{table} + + switch application.Scaling.Type { + case sca.SCALINGTYPE_SCALING_TYPE_MANUAL: + table.AddRow("SCALING TYPE", "MANUAL") + table.AddRow("INSTANCES", application.GetScaling().ManualScaling.Instances) + case sca.SCALINGTYPE_SCALING_TYPE_AUTO: + table.AddRow("SCALING TYPE", "AUTO") + table.AddRow("MIN INSTANCES", application.GetScaling().AutoScaling.MinInstances) + table.AddRow("MAX INSTANCES", application.GetScaling().AutoScaling.MaxInstances) + rules := application.GetScaling().AutoScaling.Rules + if len(rules) > 0 { + tablesToDisplay = append(tablesToDisplay, buildScalingRulesTable(rules)) + } + } containersTable := tables.NewTable() containersTable.SetTitle("Application Containers") - containersTable.SetHeader("NAME", "IMAGE", "CPU", "MEMORY") + containersTable.SetHeader("NAME", "IMAGE", "CPU", "MEMORY", "COMMANDS", "ARGS") + + environmentVarTables := []tables.Table{} for _, c := range application.Containers { + envVarTable := buildEnvVarsTable(c) + if envVarTable != nil { + environmentVarTables = append(environmentVarTables, *envVarTable) + } + commands := "-" + if len(c.Command) > 0 { + commands = strings.Join(c.Command, " ") + } + args := "-" + if len(c.Command) > 0 { + args = strings.Join(c.Args, " ") + } + containersTable.AddRow( c.Name, c.Image, *c.Cpu, *c.Memory, + commands, + args, ) } - err := tables.DisplayTables(p, []tables.Table{table, containersTable}) + tablesToDisplay = append(tablesToDisplay, containersTable) + err := tables.DisplayTables(p, append(tablesToDisplay, environmentVarTables...)) if err != nil { return fmt.Errorf("render table: %w", err) } @@ -148,3 +192,77 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiGetApplicationRequest { return apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID) } + +func buildScalingRulesTable(rules []sca.ScaleRule) tables.Table { + table := tables.NewTable() + table.SetTitle("Autoscaling rules") + table.SetHeader("NAME", "TYPE", "PARAMETERS", "SECRETS MAPPING") + for _, rule := range rules { + ruleType := "-" + parameters := "-" + secretsMapping := "-" + switch rule.Type { + case sca.RULETYPE_RULE_TYPE_HTTP: + ruleType = "HTTP" + params := []string{} + if rule.GetHttpRule().Concurrency != nil && *rule.GetHttpRule().Concurrency > 0 { + params = append(params, fmt.Sprintf("Concurrency: %d", *rule.GetHttpRule().Concurrency)) + } + if rule.GetHttpRule().Rps != nil && *rule.GetHttpRule().Rps > 0 { + params = append(params, fmt.Sprintf("RPS: %d", *rule.GetHttpRule().Rps)) + } + parameters = strings.Join(params, "\n") + secretsMapping = "-" + case sca.RULETYPE_RULE_TYPE_CUSTOM: + ruleType = "CUSTOM" + params := []string{} + if rule.GetCustomRule().Type != "" { + params = append(params, fmt.Sprintf("TYPE: %s", rule.GetCustomRule().Type)) + } + for _, p := range rule.GetCustomRule().Parameters { + params = append(params, fmt.Sprintf("%s: %s", p.Name, p.Value)) + + } + parameters = strings.Join(params, "\n") + sm := make([]string, 0, len(rule.GetCustomRule().SecretsMapping)) + for _, s := range rule.GetCustomRule().SecretsMapping { + sm = append(sm, fmt.Sprintf("%s: %s", s.Parameter, s.Secret)) + } + if len(sm) > 0 { + secretsMapping = strings.Join(sm, "\n") + } + } + + table.AddRow( + rule.Name, + ruleType, + parameters, + secretsMapping, + ) + table.AddSeparator() + } + + return table +} + +func buildEnvVarsTable(container sca.Container) *tables.Table { + if len(container.EnvironmentVariables) == 0 { + return nil + } + table := tables.NewTable() + table.SetTitle(fmt.Sprintf("%s environment variables", container.Name)) + table.SetHeader("NAME", "ORIGIN", "VALUE / SECRET REF") + for _, e := range container.EnvironmentVariables { + origin := "MANUAL" + if e.Origin != nil && *e.Origin == sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_SECRET { + origin = "SECRET" + } + table.AddRow( + e.Key, + origin, + e.Value, + ) + table.AddSeparator() + } + return &table +} diff --git a/internal/cmd/sca/application/update/update.go b/internal/cmd/sca/application/update/update.go index b553d3e52..bf8319a50 100644 --- a/internal/cmd/sca/application/update/update.go +++ b/internal/cmd/sca/application/update/update.go @@ -3,6 +3,7 @@ package update import ( "context" "fmt" + "slices" "github.com/spf13/cobra" sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" @@ -15,6 +16,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + scautils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" ) @@ -22,7 +24,6 @@ import ( const ( applicationIDArg = "APPLICATION_ID" environmentIDFlag = "environment-id" - nameFlag = "name" imageFlag = "image" publicFlag = "public" externalPortFlag = "external-port" @@ -31,26 +32,46 @@ const ( instancesFlag = "instances" minInstancesFlag = "min-instances" maxInstancesFlag = "max-instances" + rpsFlag = "http-rule-rps" + concurrencyFlag = "http-rule-concurrency" scaleToZeroFlag = "scale-to-zero" + envVarsFlag = "environment-vars" + commandsFlag = "commands" + argsFlag = "args" stoppedFlag = "stopped" ) +const ( + scalingTypeManual = "manual" + scalingTypeAuto = "auto" +) + +var scalingTypeFlag = flags.StringEnumFlag( + "scaling-type", + []string{scalingTypeManual, scalingTypeAuto}, + "Scaling type", + flags.StringEnumDefaultValue(scalingTypeManual), +) + type inputModel struct { *globalflags.GlobalFlagModel EnvironmentID string ApplicationID string Image string - ScalingType string + EnvironmentVariables map[string]string + Commands []string + Args []string + ScalingType *string Public *bool - ContainerExternalPort int32 - CPU int32 - Memory int32 - Instances int32 - MinInstances int32 - MaxInstances int32 - ScaleToZero bool - Concurrency int32 - RPS int32 + ContainerExternalPort *int32 + CPU *int32 + Memory *int32 + Instances *int32 + MinInstances *int32 + MaxInstances *int32 + ScaleToZero *bool + Concurrency *int32 + RPS *int32 Stopped *bool } @@ -63,12 +84,34 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( // TODO: fix examples examples.NewExample( - `Update a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application describe xxx --environment-id yyy"), - examples.NewExample( - `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, - "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + `Update the number of instances of a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit sca application update xxx --instances 2 --environment-id yyy"), ), + PreRunE: func(cmd *cobra.Command, args []string) error { + updateFlags := []string{ + imageFlag, + publicFlag, + externalPortFlag, + cpuFlag, + memoryFlag, + instancesFlag, + minInstancesFlag, + maxInstancesFlag, + scaleToZeroFlag, + concurrencyFlag, + rpsFlag, + envVarsFlag, + commandsFlag, + argsFlag, + stoppedFlag, + } + + if !slices.ContainsFunc(updateFlags, cmd.Flags().Changed) { + return fmt.Errorf("at least one field should be updated") + } + + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -89,7 +132,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - req := buildRequest(ctx, model, apiClient) + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } resp, err := req.Execute() if err != nil { return fmt.Errorf("update application: %w", err) @@ -115,49 +161,237 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return cmd } -func containersFromInput(model *inputModel) []sca.Container { - container := sca.Container{} +func containersFromInput(containers []sca.Container, model *inputModel) []sca.Container { + if len(containers) == 0 { + return []sca.Container{{ + Name: "container-1", + Image: model.Image, + Cpu: model.CPU, + Memory: model.Memory, + EnvironmentVariables: scautils.EnvironmentVariablesFromMap(model.EnvironmentVariables), + }} + } + updateContainer := false if model.Image != "" { updateContainer = true - container.Image = model.Image + containers[0].Image = model.Image + } + if model.CPU != nil { + updateContainer = true + containers[0].Cpu = model.CPU } - if model.CPU != 0 { + if model.Memory != nil { updateContainer = true - container.Cpu = &model.CPU + containers[0].Memory = model.Memory } - if model.Memory != 0 { + + if model.Commands != nil { + containers[0].Command = model.Commands + } + + if model.Args != nil { + containers[0].Args = model.Args + } + + for key, val := range model.EnvironmentVariables { + found := false updateContainer = true - container.Memory = &model.Memory + for i, envVar := range containers[0].EnvironmentVariables { + if envVar.Key == key { + containers[0].EnvironmentVariables[i].Origin = sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr() + containers[0].EnvironmentVariables[i].Value = val + found = true + } + } + if !found { + containers[0].EnvironmentVariables = append(containers[0].EnvironmentVariables, sca.EnvVar{ + Key: key, + Origin: sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr(), + Value: val, + }) + } + } if updateContainer { - return []sca.Container{container} + return containers } return nil } func networkFromInput(model *inputModel) *sca.Network { - if model.Public == nil || model.ContainerExternalPort == 0 { + if model.Public == nil && model.ContainerExternalPort == nil { return nil } - return &sca.Network{ - PublicIngress: *model.Public, - Port: &model.ContainerExternalPort, + network := &sca.Network{} + + if model.Public != nil { + network.PublicIngress = *model.Public + } + + if model.ContainerExternalPort != nil { + network.Port = model.ContainerExternalPort } + + return network } -func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiUpdateApplicationRequest { +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) (*sca.ApiUpdateApplicationRequest, error) { + current, err := apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID).Execute() + if err != nil { + return nil, fmt.Errorf("failed to get current application instance: %w", err) + } + + // scaling + updatedScaling, err := buildScalingConfig(model, current) + if err != nil { + return nil, err + } + payload := sca.UpdateApplicationPayload{ + Scaling: updatedScaling, Stopped: model.Stopped, } - payload.Containers = containersFromInput(model) + + payload.Containers = containersFromInput(current.Containers, model) payload.Network = networkFromInput(model) - return apiClient.DefaultAPI.UpdateApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID). - UpdateApplicationPayload(payload) + return new(apiClient.DefaultAPI.UpdateApplication(ctx, model.ProjectId, model.EnvironmentID, model.ApplicationID). + UpdateApplicationPayload(payload)), nil +} + +func buildScalingConfig(model *inputModel, current *sca.Application) (*sca.Scaling, error) { + // input's scaling type is different from current + if model.ScalingType != nil && *model.ScalingType != scautils.HumanReadableScalingType(current.GetScaling().Type) { + switch *model.ScalingType { + case scalingTypeManual: + if model.Instances == nil { + return nil, &errors.FlagValidationError{ + Flag: instancesFlag, + Details: "required flag if replacing scaling type to manual", + } + } + return &sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: *model.Instances, + }, + }, nil + case scalingTypeAuto: + autoScaling, err := buildAutoscalingConfig(model, nil) + if err != nil { + return nil, err + } + return &sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_AUTO, + AutoScaling: autoScaling, + }, nil + } + } + + // input's scaling type is ni or equal to current + + switch current.Scaling.Type { + case sca.SCALINGTYPE_SCALING_TYPE_MANUAL: + instances := current.Scaling.ManualScaling.Instances + if model.Instances != nil { + instances = *model.Instances + } + return &sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: instances, + }, + }, nil + case sca.SCALINGTYPE_SCALING_TYPE_AUTO: + autoScaling, err := buildAutoscalingConfig(model, current.Scaling.AutoScaling) + if err != nil { + return nil, err + } + return &sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_AUTO, + AutoScaling: autoScaling, + }, nil + default: + return ¤t.Scaling, nil + } +} + +func buildAutoscalingConfig(model *inputModel, current *sca.AutoScaling) (*sca.AutoScaling, error) { + if model.Instances != nil { + return nil, &errors.FlagValidationError{ + Flag: instancesFlag, + Details: "Instances of an application with autoscale config can't be updated", + } + } + + autoScaling := &sca.AutoScaling{} + if current != nil { + autoScaling = current + } + + if model.ScaleToZero != nil { + autoScaling.AllowScaleToZero = model.ScaleToZero + } + + if model.MinInstances != nil { + autoScaling.MinInstances = *model.MinInstances + } + + if model.MaxInstances != nil { + autoScaling.MaxInstances = *model.MaxInstances + } + + if autoScaling.MaxInstances == 0 { + autoScaling.MaxInstances = autoScaling.MinInstances + } + + if model.RPS != nil || model.Concurrency != nil { + autoScaling.Rules = buildScalingRules(model, autoScaling.Rules) + } + + if len(autoScaling.Rules) == 0 { + return nil, &errors.OneOfFlagsIsMissing{ + MissingFlags: []string{rpsFlag, concurrencyFlag}, + SetFlag: fmt.Sprintf("--%s=%s", scalingTypeFlag.Name(), scalingTypeAuto), + } + } + + return autoScaling, nil +} + +func buildScalingRules(model *inputModel, current []sca.ScaleRule) []sca.ScaleRule { + if model.Concurrency == nil && model.RPS == nil { + return current + } + found := false + for _, rule := range current { + if rule.Type == sca.RULETYPE_RULE_TYPE_HTTP { + found = true + if model.Concurrency != nil { + rule.HttpRule.Concurrency = model.Concurrency + } + if model.RPS != nil { + rule.HttpRule.Rps = model.RPS + } + } + } + + if found { + return current + } + + return append(current, sca.ScaleRule{ + Type: sca.RULETYPE_RULE_TYPE_HTTP, + Name: "http-scaling-rule", + HttpRule: &sca.HttpScaleRule{ + Concurrency: model.Concurrency, + Rps: model.RPS, + }, + }) } func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { @@ -172,6 +406,28 @@ func outputResult(p *print.Printer, model *inputModel, application *sca.Applicat func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") cmd.Flags().Bool(stoppedFlag, false, "Stopped") + // container + cmd.Flags().String(imageFlag, "", "Container image") + cmd.Flags().Int32(cpuFlag, 0, "The dedicated virtual CPU processing power allocated per container instance") + cmd.Flags().Int32(memoryFlag, 0, "The total amount of memory (RAM) allocated per container instance") + cmd.Flags().StringToString(envVarsFlag, nil, "Environment variables to inject into the application") + cmd.Flags().StringSlice(commandsFlag, nil, "Commands to execute in the application container") + cmd.Flags().StringSlice(argsFlag, nil, "Arguments to pass to the application container command") + // scaling + scalingTypeFlag.Register(cmd.Flags()) + cmd.Flags().Int32(instancesFlag, 0, "The number of application instances (if manually scaled)") + cmd.Flags().Int32(minInstancesFlag, 0, "The minimum number of application instances (if autoscaling is enabled)") + cmd.Flags().Int32(maxInstancesFlag, 0, "The maximum number of application instances (if autoscaling is enabled)") + cmd.Flags().Bool(scaleToZeroFlag, false, "Enable scale to zero (if autoscaling is enabled)") + cmd.Flags().Int32(concurrencyFlag, 0, "Target number of in-flight requests to trigger autoscaling (if autoscaling is enabled)") + cmd.Flags().Int32(rpsFlag, 0, "Target number of requests per second to trigger autoscaling (if autoscaling is enabled)") + // networking + cmd.Flags().Bool(publicFlag, false, "Exposes your application securely to the public internet via HTTPS endpoint") + cmd.Flags().Int32(externalPortFlag, 0, "Container external exposed port") + + cmd.MarkFlagsMutuallyExclusive(instancesFlag, minInstancesFlag) + cmd.MarkFlagsMutuallyExclusive(instancesFlag, maxInstancesFlag) + cmd.MarkFlagsMutuallyExclusive(instancesFlag, scaleToZeroFlag) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -187,11 +443,30 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu environmentID = globalFlags.ProjectId } + var envVars map[string]string + if env := flags.FlagToStringToStringPointer(p, cmd, envVarsFlag); env != nil { + envVars = *env + } + model := inputModel{ - GlobalFlagModel: globalFlags, - EnvironmentID: environmentID, - ApplicationID: applicationID, - Stopped: flags.FlagToBoolPointer(p, cmd, stoppedFlag), + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + ApplicationID: applicationID, + Stopped: flags.FlagToBoolPointer(p, cmd, stoppedFlag), + Memory: flags.FlagToInt32Pointer(p, cmd, memoryFlag), + CPU: flags.FlagToInt32Pointer(p, cmd, cpuFlag), + EnvironmentVariables: envVars, + Commands: flags.FlagToStringSliceValue(p, cmd, commandsFlag), + Args: flags.FlagToStringSliceValue(p, cmd, argsFlag), + ScalingType: scalingTypeFlag.Ptr(), + Instances: flags.FlagToInt32Pointer(p, cmd, instancesFlag), + MinInstances: flags.FlagToInt32Pointer(p, cmd, minInstancesFlag), + MaxInstances: flags.FlagToInt32Pointer(p, cmd, maxInstancesFlag), + ScaleToZero: flags.FlagToBoolPointer(p, cmd, scaleToZeroFlag), + Concurrency: flags.FlagToInt32Pointer(p, cmd, concurrencyFlag), + RPS: flags.FlagToInt32Pointer(p, cmd, rpsFlag), + Public: flags.FlagToBoolPointer(p, cmd, publicFlag), + ContainerExternalPort: flags.FlagToInt32Pointer(p, cmd, externalPortFlag), } p.DebugInputModel(model) diff --git a/internal/pkg/services/sca/utils/utils.go b/internal/pkg/services/sca/utils/utils.go index c2ef8e95b..894a7b8bb 100644 --- a/internal/pkg/services/sca/utils/utils.go +++ b/internal/pkg/services/sca/utils/utils.go @@ -50,3 +50,29 @@ func ApplicationStateToStr(isStopped bool) string { } return "Stopped" } + +func HumanReadableScalingType(st sca.ScalingType) string { + switch st { + case sca.SCALINGTYPE_SCALING_TYPE_MANUAL: + return "manual" + case sca.SCALINGTYPE_SCALING_TYPE_AUTO: + return "auto" + default: + return "-" + } +} + +func EnvironmentVariablesFromMap(m map[string]string) []sca.EnvVar { + var envVars []sca.EnvVar + if m != nil { + envVars = make([]sca.EnvVar, 0, len(m)) + for k, v := range m { + envVars = append(envVars, sca.EnvVar{ + Key: k, + Value: v, + Origin: sca.ENVVARTYPE_ENV_FROM_SOURCE_TYPE_MANUAL.Ptr(), + }) + } + } + return envVars +} From 9aec52408d09edb177cdbf41927e8f5fc779d9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Sun, 4 Oct 2026 19:28:57 +0100 Subject: [PATCH 09/11] fix(sca): move o alpha package --- internal/cmd/alpha/alpha.go | 40 +++++++++++++++++++ .../sca/application/application.go | 0 .../create_from_payload.go | 22 ++++++---- .../create_from_payload_test.go | 0 .../sca/application/create/create.go | 10 ++--- .../sca/application/create/create_test.go | 0 .../sca/application/delete/delete.go | 2 +- .../sca/application/delete/delete_test.go | 0 .../sca/application/describe/describe.go | 4 +- .../sca/application/describe/describe_test.go | 0 .../generate-payload/generate_payload.go | 10 ++--- .../generate-payload/generate_payload_test.go | 0 .../{ => alpha}/sca/application/list/list.go | 8 ++-- .../sca/application/list/list_test.go | 0 .../update-from-payload.go | 8 ++-- .../update_from_payload_test.go | 0 .../sca/application/update/update.go | 6 ++- internal/cmd/{ => alpha}/sca/sca.go | 0 18 files changed, 80 insertions(+), 30 deletions(-) create mode 100644 internal/cmd/alpha/alpha.go rename internal/cmd/{ => alpha}/sca/application/application.go (100%) rename internal/cmd/{ => alpha}/sca/application/create-from-payload/create_from_payload.go (79%) rename internal/cmd/{ => alpha}/sca/application/create-from-payload/create_from_payload_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/create/create.go (95%) rename internal/cmd/{ => alpha}/sca/application/create/create_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/delete/delete.go (97%) rename internal/cmd/{ => alpha}/sca/application/delete/delete_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/describe/describe.go (97%) rename internal/cmd/{ => alpha}/sca/application/describe/describe_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/generate-payload/generate_payload.go (91%) rename internal/cmd/{ => alpha}/sca/application/generate-payload/generate_payload_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/list/list.go (95%) rename internal/cmd/{ => alpha}/sca/application/list/list_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/update-from-payload/update-from-payload.go (92%) rename internal/cmd/{ => alpha}/sca/application/update-from-payload/update_from_payload_test.go (100%) rename internal/cmd/{ => alpha}/sca/application/update/update.go (97%) rename internal/cmd/{ => alpha}/sca/sca.go (100%) diff --git a/internal/cmd/alpha/alpha.go b/internal/cmd/alpha/alpha.go new file mode 100644 index 000000000..40e94b74a --- /dev/null +++ b/internal/cmd/alpha/alpha.go @@ -0,0 +1,40 @@ +package alpha + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "alpha", + Short: "Contains alpha STACKIT CLI commands", + Long: fmt.Sprintf("%s\n%s", + "Contains beta STACKIT CLI commands.", + "The commands under this group are still in an alpha state, and functionality may be incomplete or have breaking changes."), + Args: args.NoArgs, + Run: utils.CmdHelp, + Example: examples.Build( + examples.NewExample( + "See the currently available beta commands", + "$ stackit alpha --help"), + examples.NewExample( + "Execute a alpha command", + "$ stackit alpha MY_COMMAND"), + ), + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(sca.NewCmd(params)) +} diff --git a/internal/cmd/sca/application/application.go b/internal/cmd/alpha/sca/application/application.go similarity index 100% rename from internal/cmd/sca/application/application.go rename to internal/cmd/alpha/sca/application/application.go diff --git a/internal/cmd/sca/application/create-from-payload/create_from_payload.go b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go similarity index 79% rename from internal/cmd/sca/application/create-from-payload/create_from_payload.go rename to internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go index ecdc51670..85a7ee99f 100644 --- a/internal/cmd/sca/application/create-from-payload/create_from_payload.go +++ b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go @@ -36,16 +36,24 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create-from-payload", Short: "Create a SCA application from payload", - Long: "Create a STACKIT Kubernetes Engine (SCA) application from payload.", - Args: args.NoArgs, + Long: fmt.Sprintf("%s\n%s\n%s", + "Create a STACKIT Kubernetes Engine (SCA) application from payload.", + `The payload can be provided as a JSON string or a file path prefixed with "@".`, + "See https://docs.api.stackit.cloud/documentation/sca/version/v1alpha#tag/Applications/operation/Applications_CreateApplication for information regarding the payload structure.", + ), + Args: args.NoArgs, Example: examples.Build( - // TODO: fix examples examples.NewExample( - `Create a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application describe xxx --environment-id yyy"), + `Create a SCA application using an API payload sourced from the file "./payload.json"`, + "$ stackit alpha sca cluster create-from-payload --name application-name --payload @./payload.json"), + examples.NewExample( + `Create a SCA application using an API payload provided as a JSON string`, + `$ stackit alpha sca cluster create-from-payload --name application-name --payload "{...}"`), examples.NewExample( - `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, - "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit alpha sca application generate-payload --file-path ./payload.json`, + ``, + `$ stackit alpha sca application create-from-payload --name application-name --payload @./payload.json`), ), RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/create-from-payload/create_from_payload_test.go b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go similarity index 100% rename from internal/cmd/sca/application/create-from-payload/create_from_payload_test.go rename to internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go diff --git a/internal/cmd/sca/application/create/create.go b/internal/cmd/alpha/sca/application/create/create.go similarity index 95% rename from internal/cmd/sca/application/create/create.go rename to internal/cmd/alpha/sca/application/create/create.go index 98e91f8d6..3c8fb122b 100644 --- a/internal/cmd/sca/application/create/create.go +++ b/internal/cmd/alpha/sca/application/create/create.go @@ -91,19 +91,19 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" for an environment with ID "yyy"`, - "$ stackit sca application create --name application-name --image my-image --environment-id yyy"), + "$ stackit alpha sca application create --name application-name --image my-image --environment-id yyy"), examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" with 2 instances`, - "$ stackit sca application create --name application-name --image my-image --instances 2"), + "$ stackit alpha sca application create --name application-name --image my-image --instances 2"), examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" exposing port 8888 of the container`, - "$ stackit sca application create --name application-name --image my-image --port 8888"), + "$ stackit alpha sca application create --name application-name --image my-image --port 8888"), examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" disabling public networking`, - "$ stackit sca application create --name application-name --image my-image --public=false"), + "$ stackit alpha sca application create --name application-name --image my-image --public=false"), examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" and environment variables ENV1=value1 and ENV2=value2`, - "$ stackit sca application create --name application-name --image my-image --environment-vars ENV1=value1,ENV2=value2"), + "$ stackit alpha sca application create --name application-name --image my-image --environment-vars ENV1=value1,ENV2=value2"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/create/create_test.go b/internal/cmd/alpha/sca/application/create/create_test.go similarity index 100% rename from internal/cmd/sca/application/create/create_test.go rename to internal/cmd/alpha/sca/application/create/create_test.go diff --git a/internal/cmd/sca/application/delete/delete.go b/internal/cmd/alpha/sca/application/delete/delete.go similarity index 97% rename from internal/cmd/sca/application/delete/delete.go rename to internal/cmd/alpha/sca/application/delete/delete.go index b947ad04d..45770748a 100644 --- a/internal/cmd/sca/application/delete/delete.go +++ b/internal/cmd/alpha/sca/application/delete/delete.go @@ -37,7 +37,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Delete a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application delete xxx --environment-id yyy"), + "$ stackit alpha sca application delete xxx --environment-id yyy"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/delete/delete_test.go b/internal/cmd/alpha/sca/application/delete/delete_test.go similarity index 100% rename from internal/cmd/sca/application/delete/delete_test.go rename to internal/cmd/alpha/sca/application/delete/delete_test.go diff --git a/internal/cmd/sca/application/describe/describe.go b/internal/cmd/alpha/sca/application/describe/describe.go similarity index 97% rename from internal/cmd/sca/application/describe/describe.go rename to internal/cmd/alpha/sca/application/describe/describe.go index 61ab5a491..d25bfd77e 100644 --- a/internal/cmd/sca/application/describe/describe.go +++ b/internal/cmd/alpha/sca/application/describe/describe.go @@ -41,10 +41,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Get details of a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application describe xxx --environment-id yyy"), + "$ stackit alpha sca application describe xxx --environment-id yyy"), examples.NewExample( `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, - "$ stackit sca application describe xxx --environment-id yyy --output-format json"), + "$ stackit alpha sca application describe xxx --environment-id yyy --output-format json"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/describe/describe_test.go b/internal/cmd/alpha/sca/application/describe/describe_test.go similarity index 100% rename from internal/cmd/sca/application/describe/describe_test.go rename to internal/cmd/alpha/sca/application/describe/describe_test.go diff --git a/internal/cmd/sca/application/generate-payload/generate_payload.go b/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go similarity index 91% rename from internal/cmd/sca/application/generate-payload/generate_payload.go rename to internal/cmd/alpha/sca/application/generate-payload/generate_payload.go index 98684c994..061b93acf 100644 --- a/internal/cmd/sca/application/generate-payload/generate_payload.go +++ b/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go @@ -46,19 +46,19 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Generate a payload with default values, and adapt it with custom values for the different configuration options`, - `$ stackit sca application generate-payload --file-path ./payload.json`, + `$ stackit alpha sca application generate-payload --file-path ./payload.json`, ``, - `$ stackit sca application create-from-payload --name application-name --payload @./payload.json`, + `$ stackit alpha sca application create-from-payload --name application-name --payload @./payload.json`, ), examples.NewExample( `Generate a payload with values of an application, and adapt it with custom values for the different configuration options`, - `$ stackit sca application generate-payload --application-id xxx --file-path ./payload.json`, + `$ stackit alpha sca application generate-payload --application-id xxx --file-path ./payload.json`, ``, - `$ stackit sca application update-from-payload --payload @./payload.json`, + `$ stackit alpha sca application update-from-payload --payload @./payload.json`, ), examples.NewExample( `Generate a payload with values of an application, and preview it in the terminal`, - `$ stackit sca application generate-payload --application-id xxx`, + `$ stackit alpha sca application generate-payload --application-id xxx`, ), ), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/sca/application/generate-payload/generate_payload_test.go b/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go similarity index 100% rename from internal/cmd/sca/application/generate-payload/generate_payload_test.go rename to internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go diff --git a/internal/cmd/sca/application/list/list.go b/internal/cmd/alpha/sca/application/list/list.go similarity index 95% rename from internal/cmd/sca/application/list/list.go rename to internal/cmd/alpha/sca/application/list/list.go index a7ee91291..09185f489 100644 --- a/internal/cmd/sca/application/list/list.go +++ b/internal/cmd/alpha/sca/application/list/list.go @@ -44,16 +44,16 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `List all SCA applications`, - "$ stackit sca application list"), + "$ stackit alpha sca application list"), examples.NewExample( `List all SCA applications from environment with ID "xxx"`, - "$ stackit sca application list --environment-id xxx"), + "$ stackit alpha sca application list --environment-id xxx"), examples.NewExample( `List all SCA applications in JSON format`, - "$ stackit sca application list --output-format json"), + "$ stackit alpha sca application list --output-format json"), examples.NewExample( `List up to 10 SCA applications`, - "$ stackit sca application list --limit 10"), + "$ stackit alpha sca application list --limit 10"), ), RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/list/list_test.go b/internal/cmd/alpha/sca/application/list/list_test.go similarity index 100% rename from internal/cmd/sca/application/list/list_test.go rename to internal/cmd/alpha/sca/application/list/list_test.go diff --git a/internal/cmd/sca/application/update-from-payload/update-from-payload.go b/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go similarity index 92% rename from internal/cmd/sca/application/update-from-payload/update-from-payload.go rename to internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go index 991012322..c62a8aa86 100644 --- a/internal/cmd/sca/application/update-from-payload/update-from-payload.go +++ b/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go @@ -42,15 +42,15 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Update a SCA application using an API payload sourced from the file "./payload.json"`, - "$ stackit sca application update-from-payload my-application-id --payload @./payload.json"), + "$ stackit alpha sca application update-from-payload my-application-id --payload @./payload.json"), examples.NewExample( `Update a SCA application using an API payload provided as a JSON string`, - `$ stackit sca application update-from-payload my-application-id --payload "{...}"`), + `$ stackit alpha sca application update-from-payload my-application-id --payload "{...}"`), examples.NewExample( `Generate a payload with the current values of an application, and adapt it with custom values for the different configuration options`, - `$ stackit sca application generate-payload --application-id application-id > ./payload.json`, + `$ stackit alpha sca application generate-payload --application-id application-id > ./payload.json`, ``, - `$ stackit sca application update-from-payload application-id --payload @./payload.json`), + `$ stackit alpha sca application update-from-payload application-id --payload @./payload.json`), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/sca/application/update-from-payload/update_from_payload_test.go b/internal/cmd/alpha/sca/application/update-from-payload/update_from_payload_test.go similarity index 100% rename from internal/cmd/sca/application/update-from-payload/update_from_payload_test.go rename to internal/cmd/alpha/sca/application/update-from-payload/update_from_payload_test.go diff --git a/internal/cmd/sca/application/update/update.go b/internal/cmd/alpha/sca/application/update/update.go similarity index 97% rename from internal/cmd/sca/application/update/update.go rename to internal/cmd/alpha/sca/application/update/update.go index bf8319a50..9f618d701 100644 --- a/internal/cmd/sca/application/update/update.go +++ b/internal/cmd/alpha/sca/application/update/update.go @@ -82,10 +82,12 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Long: "Update a STACKIT Kubernetes Engine (SCA) application.", Args: args.SingleArg(applicationIDArg, nil), Example: examples.Build( - // TODO: fix examples examples.NewExample( `Update the number of instances of a SCA application with ID "xxx" from an environment with ID "yyy"`, - "$ stackit sca application update xxx --instances 2 --environment-id yyy"), + "$ stackit alpha sca application update xxx --instances 2 --environment-id yyy"), + examples.NewExample( + `Update the container image of a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit alpha sca application update xxx --image new-image --environment-id yyy"), ), PreRunE: func(cmd *cobra.Command, args []string) error { updateFlags := []string{ diff --git a/internal/cmd/sca/sca.go b/internal/cmd/alpha/sca/sca.go similarity index 100% rename from internal/cmd/sca/sca.go rename to internal/cmd/alpha/sca/sca.go From ca2af82f33d740195117043bed9bc819b9ab13c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Sun, 4 Oct 2026 20:51:31 +0100 Subject: [PATCH 10/11] solve pr comments --- .../cmd/alpha/sca/application/application.go | 16 ++-- .../create_from_payload.go | 31 ++++--- .../create_from_payload_test.go | 7 ++ .../alpha/sca/application/create/create.go | 58 +++++++------ .../sca/application/create/create_test.go | 85 +++++++++++++++++-- .../alpha/sca/application/delete/delete.go | 32 ++++++- .../sca/application/describe/describe.go | 30 +++++-- .../generate-payload/generate_payload.go | 6 +- .../cmd/alpha/sca/application/list/list.go | 14 ++- .../update-from-payload.go | 11 ++- .../alpha/sca/application/update/update.go | 27 +++--- internal/cmd/alpha/sca/sca.go | 2 +- internal/cmd/root.go | 4 +- internal/pkg/services/sca/utils/utils.go | 26 +++++- internal/pkg/services/sca/utils/utils_test.go | 12 +-- qa.json | 47 ---------- 16 files changed, 265 insertions(+), 143 deletions(-) delete mode 100644 qa.json diff --git a/internal/cmd/alpha/sca/application/application.go b/internal/cmd/alpha/sca/application/application.go index d53d40e8a..17391d5e6 100644 --- a/internal/cmd/alpha/sca/application/application.go +++ b/internal/cmd/alpha/sca/application/application.go @@ -3,14 +3,14 @@ package application import ( "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create" - createfrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/create-from-payload" - "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/delete" - "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/describe" - generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/generate-payload" - "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/list" - "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/update" - updatefrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application/update-from-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/create" + createfrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/create-from-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/update" + updatefrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/update-from-payload" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" diff --git a/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go index 85a7ee99f..65ab48a6c 100644 --- a/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go +++ b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go @@ -37,7 +37,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Use: "create-from-payload", Short: "Create a SCA application from payload", Long: fmt.Sprintf("%s\n%s\n%s", - "Create a STACKIT Kubernetes Engine (SCA) application from payload.", + "Create a STACKIT Container Applications (SCA) application from payload.", `The payload can be provided as a JSON string or a file path prefixed with "@".`, "See https://docs.api.stackit.cloud/documentation/sca/version/v1alpha#tag/Applications/operation/Applications_CreateApplication for information regarding the payload structure.", ), @@ -91,9 +91,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } } - outputResult(params.Printer, model, resp) - - return nil + return outputResult(params.Printer, model, resp) }, } @@ -106,13 +104,19 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClie CreateApplicationPayload(*model.Payload) } -func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { - operationState := "Created" - if model.Async { - operationState = "Triggered creation of" +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) error { + if application == nil { + return fmt.Errorf("create application response is empty") } - p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return p.OutputResult(model.OutputFormat, application, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return nil + }) } func configureFlags(cmd *cobra.Command) { @@ -140,7 +144,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, payload = &sca.CreateApplicationPayload{} err := json.Unmarshal([]byte(*payloadValue), payload) if err != nil { - return nil, fmt.Errorf("enconde payload: %w", err) + return nil, fmt.Errorf("encode payload: %w", err) + } + } + + if payload == nil { + return nil, &errors.FlagValidationError{ + Flag: payloadFlag, + Details: "Received empty payload", } } diff --git a/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go index 226a8e61f..3a5f17f9b 100644 --- a/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go +++ b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go @@ -147,6 +147,13 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + desc: "empty json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "{}" + }), + isValid: false, + }, } for _, tt := range tests { diff --git a/internal/cmd/alpha/sca/application/create/create.go b/internal/cmd/alpha/sca/application/create/create.go index 3c8fb122b..7b7bfd576 100644 --- a/internal/cmd/alpha/sca/application/create/create.go +++ b/internal/cmd/alpha/sca/application/create/create.go @@ -24,12 +24,13 @@ const ( environmentIDFlag = "environment-id" nameFlag = "name" // Container config - imageFlag = "image" - cpuFlag = "cpu" - memoryFlag = "memory" - envVarsFlag = "environment-vars" - commandsFlag = "commands" - argsFlag = "args" + containerNameFlag = "container-name" + imageFlag = "image" + cpuFlag = "cpu" + memoryFlag = "memory" + envVarsFlag = "environment-vars" + commandsFlag = "commands" + argsFlag = "args" // Scaling instancesFlag = "instances" minInstancesFlag = "min-instances" @@ -40,9 +41,7 @@ const ( // Networking publicFlag = "public" externalPortFlag = "external-port" -) -const ( scalingTypeManual = "manual" scalingTypeAuto = "auto" @@ -65,6 +64,7 @@ type inputModel struct { *globalflags.GlobalFlagModel EnvironmentID string Name string + ContainerName string Image string CPU int32 Memory int32 @@ -86,7 +86,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a SCA application", - Long: "Create a STACKIT Kubernetes Engine (SCA) application.", + Long: "Create a STACKIT Container Applications (SCA) application.", Args: args.NoArgs, Example: examples.Build( examples.NewExample( @@ -97,7 +97,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { "$ stackit alpha sca application create --name application-name --image my-image --instances 2"), examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" exposing port 8888 of the container`, - "$ stackit alpha sca application create --name application-name --image my-image --port 8888"), + "$ stackit alpha sca application create --name application-name --image my-image --external-port 8888"), examples.NewExample( `Create a SCA application with name "application-name" and image "my-image" disabling public networking`, "$ stackit alpha sca application create --name application-name --image my-image --public=false"), @@ -141,9 +141,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } } - outputResult(params.Printer, model, resp) - - return nil + return outputResult(params.Printer, model, resp) }, } @@ -217,19 +215,26 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClie CreateApplicationPayload(payload) } -func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { - operationState := "Created" - if model.Async { - operationState = "Triggered creation of" +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) error { + if application == nil { + return fmt.Errorf("create application response is empty") } - p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return p.OutputResult(model.OutputFormat, application, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return nil + }) } func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") cmd.Flags().String(nameFlag, "", "Application display name") // container + cmd.Flags().String(containerNameFlag, defaultContainerName, "Container name") cmd.Flags().String(imageFlag, "", "Container image") cmd.Flags().Int32(cpuFlag, defaultCPU, "The dedicated virtual CPU processing power allocated per container instance") cmd.Flags().Int32(memoryFlag, defaultMemory, "The total amount of memory (RAM) allocated per container instance") @@ -240,7 +245,7 @@ func configureFlags(cmd *cobra.Command) { scalingTypeFlag.Register(cmd.Flags()) cmd.Flags().Int32(instancesFlag, defaultInstances, "The number of application instances (if manually scaled)") cmd.Flags().Int32(minInstancesFlag, 1, "The minimum number of application instances (if autoscaling is enabled)") - cmd.Flags().Int32(maxInstancesFlag, 1, "The maximum number of application instances (if autoscaling is enabled)") + cmd.Flags().Int32(maxInstancesFlag, 0, "The maximum number of application instances (if autoscaling is enabled)") cmd.Flags().Int32(rpsFlag, 0, "Target number of requests per second to trigger autoscaling (if autoscaling is enabled)") cmd.Flags().Int32(concurrencyFlag, 0, "Target number of in-flight requests to trigger autoscaling (if autoscaling is enabled)") cmd.Flags().Bool(scaleToZeroFlag, false, "Enable scale to zero (if autoscaling is enabled)") @@ -283,11 +288,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, } } - maxInstances := flags.FlagWithDefaultToInt32Value(p, cmd, maxInstancesFlag) - if maxInstances == 0 { - maxInstances = minInstances + maxInstances := minInstances + if i := flags.FlagToInt32Pointer(p, cmd, maxInstancesFlag); i != nil { + maxInstances = *i } - if maxInstances < minInstances || minInstances > 10 { + if maxInstances < minInstances || maxInstances > 10 { return nil, &errors.FlagValidationError{ Flag: minInstancesFlag, Details: fmt.Sprintf("must be an integer between minInstances (%d) and 10", minInstances), @@ -303,10 +308,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, } cpu := flags.FlagWithDefaultToInt32Value(p, cmd, cpuFlag) - if cpu%1000 != 0 { + if cpu <= 0 || cpu%1000 != 0 { return nil, &errors.FlagValidationError{ - Flag: instancesFlag, - Details: "must be divisible by 1000", + Flag: cpuFlag, + Details: "must be a valid value divisible by 1000", } } @@ -324,6 +329,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, GlobalFlagModel: globalFlags, EnvironmentID: environmentID, Name: flags.FlagToStringValue(p, cmd, nameFlag), + ContainerName: flags.FlagWithDefaultToStringValue(p, cmd, containerNameFlag), Image: flags.FlagToStringValue(p, cmd, imageFlag), CPU: cpu, Memory: flags.FlagWithDefaultToInt32Value(p, cmd, memoryFlag), diff --git a/internal/cmd/alpha/sca/application/create/create_test.go b/internal/cmd/alpha/sca/application/create/create_test.go index 807a32549..e711ddf84 100644 --- a/internal/cmd/alpha/sca/application/create/create_test.go +++ b/internal/cmd/alpha/sca/application/create/create_test.go @@ -32,6 +32,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st environmentIDFlag: testEnvironmentID, nameFlag: "test-application-name", + containerNameFlag: "my-container", imageFlag: "test-image", externalPortFlag: "8888", cpuFlag: "2000", @@ -39,12 +40,9 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st instancesFlag: "2", publicFlag: "true", scalingTypeFlag.Name(): scalingTypeManual, - // minInstancesFlag: "", - // maxInstancesFlag: "", - // scaleToZeroFlag: "", - envVarsFlag: "ENV1=val1,ENV2=val2", - commandsFlag: "/bin/sh,-c", - argsFlag: "echo 'test'", + envVarsFlag: "ENV1=val1,ENV2=val2", + commandsFlag: "/bin/sh,-c", + argsFlag: "echo 'test'", } for _, mod := range mods { mod(flagValues) @@ -61,6 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { }, EnvironmentID: testEnvironmentID, Name: "test-application-name", + ContainerName: "my-container", Image: "test-image", ContainerExternalPort: 8888, ScalingType: scalingTypeManual, @@ -68,6 +67,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { CPU: 2000, Memory: 2048, Instances: 2, + MinInstances: 1, + MaxInstances: 1, EnvironmentVars: map[string]string{ "ENV1": "val1", "ENV2": "val2", @@ -98,8 +99,10 @@ func fixturePayload(mods ...func(payload *sca.CreateApplicationPayload)) sca.Cre Port: new(int32(8888)), }, Scaling: sca.Scaling{ - Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, - ManualScaling: &sca.ManualScaling{}, + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: 2, + }, }, Containers: []sca.Container{{ Name: "container-1", @@ -222,6 +225,72 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + desc: "invalid min instances", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[minInstancesFlag] = "-2" + }), + isValid: false, + }, + { + desc: "valid autoscaling config", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[maxInstancesFlag] = "3" + flagValues[rpsFlag] = "5" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ScalingType = scalingTypeAuto + model.Instances = defaultInstances + model.MinInstances = 2 + model.MaxInstances = 3 + model.RPS = 5 + }), + isValid: true, + }, + { + desc: "set min instances as max instances if not defined", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + delete(flagValues, maxInstancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[rpsFlag] = "5" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ScalingType = scalingTypeAuto + model.Instances = defaultInstances + model.MinInstances = 2 + model.MaxInstances = 2 + model.RPS = 5 + }), + isValid: true, + }, + { + desc: "invalid max instances", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + // scalingTypeFlag.Set() + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[maxInstancesFlag] = "1" + flagValues[rpsFlag] = "5" + }), + isValid: false, + }, + { + desc: "missing rps and concurrency if autoscaling is enabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + // scalingTypeFlag.Set() + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[maxInstancesFlag] = "5" + }), + isValid: false, + }, } for _, tt := range tests { diff --git a/internal/cmd/alpha/sca/application/delete/delete.go b/internal/cmd/alpha/sca/application/delete/delete.go index 45770748a..f72a32638 100644 --- a/internal/cmd/alpha/sca/application/delete/delete.go +++ b/internal/cmd/alpha/sca/application/delete/delete.go @@ -13,7 +13,9 @@ import ( "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/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/types" ) @@ -32,7 +34,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "delete", Short: "Delete a SCA application", - Long: "Delete a STACKIT Kubernetes Engine (SCA) application.", + Long: "Delete a STACKIT Container Applications (SCA) application.", Args: args.SingleArg(applicationIDArg, nil), Example: examples.Build( examples.NewExample( @@ -58,14 +60,38 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return &errors.ProjectIdError{} } + applicationLabel, err := utils.GetApplicationName(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID, model.ApplicationID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get application name: %v", err) + applicationLabel = model.ApplicationID + } + + environmentLabel, err := utils.GetEnvironmentName(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get environemnt name: %v", err) + environmentLabel = model.EnvironmentID + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the application %q from environment %q and product %q?", applicationLabel, environmentLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + // Call API req := buildRequest(ctx, model, apiClient) - resp, err := req.Execute() + _, err = req.Execute() if err != nil { return fmt.Errorf("delete application: %w", err) } - params.Printer.Info("Deleted application %q from environment %q\n", resp.GetId(), resp.GetEnvironmentId()) + params.Printer.Info("Deleted application %q from environment %q\n", applicationLabel, environmentLabel) return nil }, diff --git a/internal/cmd/alpha/sca/application/describe/describe.go b/internal/cmd/alpha/sca/application/describe/describe.go index d25bfd77e..aa5291b37 100644 --- a/internal/cmd/alpha/sca/application/describe/describe.go +++ b/internal/cmd/alpha/sca/application/describe/describe.go @@ -36,7 +36,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "describe", Short: "Show details of a SCA application", - Long: "Show details of a STACKIT Kubernetes Engine (SCA) application.", + Long: "Show details of a STACKIT Container Applications (SCA) application.", Args: args.SingleArg(applicationIDArg, nil), Example: examples.Build( examples.NewExample( @@ -93,17 +93,19 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic table.AddSeparator() table.AddRow("NAME", application.DisplayName) table.AddSeparator() - table.AddRow("STATUS", scautils.ApplicationStatusToStr(application.RuntimeStatus.GetCurrentStatus())) + table.AddRow("STATUS", scautils.ApplicationStatusToStr(application.GetRuntimeStatus().CurrentStatus)) table.AddSeparator() table.AddRow("STATE", scautils.ApplicationStateToStr(application.GetStopped())) table.AddSeparator() - table.AddRow("AVAILABE INSTANCES", len(application.RuntimeStatus.Instances)) - table.AddSeparator() - table.AddRow("PUBLIC INGRESS", application.Network.PublicIngress) + if application.RuntimeStatus != nil { + table.AddRow("AVAILABE INSTANCES", len(application.GetRuntimeStatus().Instances)) + table.AddSeparator() + } + table.AddRow("PUBLIC INGRESS", application.GetNetwork().PublicIngress) if application.Network.Port != nil { - table.AddRow("PUBLIC PORT", *application.Network.Port) + table.AddRow("PUBLIC PORT", *application.GetNetwork().Port) } - if len(application.RuntimeStatus.Urls) > 0 { + if len(application.GetRuntimeStatus().Urls) > 0 { table.AddRow("PUBLIC URL", application.RuntimeStatus.Urls[0]) } table.AddSeparator() @@ -143,11 +145,21 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic args = strings.Join(c.Args, " ") } + cpu := "-" + if c.Cpu != nil { + cpu = string(*c.Cpu) + } + + memory := "-" + if c.Memory != nil { + memory = string(*c.Memory) + } + containersTable.AddRow( c.Name, c.Image, - *c.Cpu, - *c.Memory, + cpu, + memory, commands, args, ) diff --git a/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go b/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go index 061b93acf..741b623b0 100644 --- a/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go +++ b/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go @@ -37,7 +37,7 @@ type inputModel struct { func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "generate-payload", - Short: "Generates a payload to create/updaet SCA applications", + Short: "Generates a payload to create/update SCA applications", Long: fmt.Sprintf("%s\n%s", "Generates a JSON payload with values to be used as --payload input for application creation or update.", "See https://docs.api.stackit.cloud/documentation/sca/version/v1alpha#tag/Applications/operation/Applications_CreateApplication for information regarding the payload structure.", @@ -127,10 +127,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, return &model, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiGetApplicationRequest { - return apiClient.DefaultAPI.GetApplication(ctx, model.ProjectId, *model.EnvironmentID, *model.ApplicationID) -} - func outputResult(p *print.Printer, filePath *string, payload *sca.CreateApplicationPayload) error { if payload == nil { return fmt.Errorf("application payload is empty") diff --git a/internal/cmd/alpha/sca/application/list/list.go b/internal/cmd/alpha/sca/application/list/list.go index 09185f489..bf7312846 100644 --- a/internal/cmd/alpha/sca/application/list/list.go +++ b/internal/cmd/alpha/sca/application/list/list.go @@ -39,7 +39,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all SCA applications", - Long: "Lists all STACKIT Kubernetes Engine (SCA) applications.", + Long: "Lists all STACKIT Container Applications (SCA) applications.", Args: args.NoArgs, Example: examples.Build( examples.NewExample( @@ -110,10 +110,18 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, 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, EnvironmentID: flags.FlagToStringValue(p, cmd, environmentIDFlag), - Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), + Limit: limit, } p.DebugInputModel(model) @@ -146,7 +154,7 @@ func outputResult(p *print.Printer, outputFormat, projectLabel string, applicati a.GetEnvironmentName(), a.GetEnvironmentId(), a.GetUrl(), - scautils.ApplicationStatusToStr(a.GetStatus()), + scautils.ApplicationStatusToStr(a.Status), scautils.ApplicationStateToStr(a.GetStopped()), ) } diff --git a/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go b/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go index c62a8aa86..97bd8f9e5 100644 --- a/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go +++ b/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go @@ -37,7 +37,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update-from-payload", Short: "Update a SCA application from payload", - Long: "Update a STACKIT Kubernetes Engine (SCA) application from payload.", + Long: "Update a STACKIT Container Applications (SCA) application from payload.", Args: args.SingleArg(applicationIDArg, nil), Example: examples.Build( examples.NewExample( @@ -140,9 +140,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu } } - payload.AdditionalProperties = nil + if payload == nil { + return nil, &errors.FlagValidationError{ + Flag: payloadFlag, + Details: "Received empty payload", + } + } - fmt.Printf("%+v\n", payload) + payload.AdditionalProperties = nil model := inputModel{ GlobalFlagModel: globalFlags, diff --git a/internal/cmd/alpha/sca/application/update/update.go b/internal/cmd/alpha/sca/application/update/update.go index 9f618d701..2566a0599 100644 --- a/internal/cmd/alpha/sca/application/update/update.go +++ b/internal/cmd/alpha/sca/application/update/update.go @@ -79,7 +79,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Update a SCA application", - Long: "Update a STACKIT Kubernetes Engine (SCA) application.", + Long: "Update a STACKIT Container Applications (SCA) application.", Args: args.SingleArg(applicationIDArg, nil), Example: examples.Build( examples.NewExample( @@ -396,15 +396,6 @@ func buildScalingRules(model *inputModel, current []sca.ScaleRule) []sca.ScaleRu }) } -func outputResult(p *print.Printer, model *inputModel, application *sca.Application) { - operationState := "Updated" - if model.Async { - operationState = "Triggered update of" - } - - p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) -} - func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") cmd.Flags().Bool(stoppedFlag, false, "Stopped") @@ -474,3 +465,19 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu p.DebugInputModel(model) return &model, nil } + +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) error { + if application == nil { + return fmt.Errorf("update application response is empty") + } + + return p.OutputResult(model.OutputFormat, application, func() error { + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return nil + }) +} diff --git a/internal/cmd/alpha/sca/sca.go b/internal/cmd/alpha/sca/sca.go index b9a80c71b..9ec84c96f 100644 --- a/internal/cmd/alpha/sca/sca.go +++ b/internal/cmd/alpha/sca/sca.go @@ -1,7 +1,7 @@ package sca import ( - "github.com/stackitcloud/stackit-cli/internal/cmd/sca/application" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 475ec8513..d5e0e7931 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/stackitcloud/stackit-cli/internal/cmd/sca" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha" "github.com/stackitcloud/stackit-cli/internal/cmd/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/pkg/types" @@ -172,6 +172,7 @@ func configureFlags(cmd *cobra.Command) error { } func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(alpha.NewCmd(params)) cmd.AddCommand(auth.NewCmd(params)) cmd.AddCommand(configCmd.NewCmd(params)) cmd.AddCommand(beta.NewCmd(params)) @@ -208,7 +209,6 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(kms.NewCmd(params)) cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(valkey.NewCmd(params)) - cmd.AddCommand(sca.NewCmd(params)) } // traverseCommands calls f for c and all of its children. diff --git a/internal/pkg/services/sca/utils/utils.go b/internal/pkg/services/sca/utils/utils.go index 894a7b8bb..0a4b1f1e8 100644 --- a/internal/pkg/services/sca/utils/utils.go +++ b/internal/pkg/services/sca/utils/utils.go @@ -1,6 +1,9 @@ package utils import ( + "context" + "fmt" + sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" ) @@ -27,8 +30,11 @@ func GetDefaultPayload() *sca.CreateApplicationPayload { } } -func ApplicationStatusToStr(status sca.CurrentStatus) string { - switch status { +func ApplicationStatusToStr(status *sca.CurrentStatus) string { + if status == nil { + return "None" + } + switch *status { case sca.CURRENTSTATUS_CURRENT_STATUS_RUNNING: return "Running" case sca.CURRENTSTATUS_CURRENT_STATUS_IDLE: @@ -76,3 +82,19 @@ func EnvironmentVariablesFromMap(m map[string]string) []sca.EnvVar { } return envVars } + +func GetApplicationName(ctx context.Context, apiClient sca.DefaultAPI, projectId, environmentID, applicaitonID string) (string, error) { + resp, err := apiClient.GetApplication(ctx, projectId, environmentID, applicaitonID).Execute() + if err != nil { + return "", fmt.Errorf("get application: %w", err) + } + return resp.DisplayName, nil +} + +func GetEnvironmentName(ctx context.Context, apiClient sca.DefaultAPI, projectId, environmentID string) (string, error) { + resp, err := apiClient.GetEnvironment(ctx, projectId, environmentID).Execute() + if err != nil { + return "", fmt.Errorf("get environment: %w", err) + } + return resp.DisplayName, nil +} diff --git a/internal/pkg/services/sca/utils/utils_test.go b/internal/pkg/services/sca/utils/utils_test.go index b99a9afd6..8ccb62a9a 100644 --- a/internal/pkg/services/sca/utils/utils_test.go +++ b/internal/pkg/services/sca/utils/utils_test.go @@ -11,14 +11,14 @@ import ( func TestApplicationStatusToStr(t *testing.T) { tests := []struct { desc string - status v1alphaapi.CurrentStatus + status *v1alphaapi.CurrentStatus expected string }{ - {desc: "Running", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_RUNNING, expected: "Running"}, - {desc: "Progressing", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_PROGRESSING, expected: "Progressing"}, - {desc: "Idle", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_IDLE, expected: "Idle"}, - {desc: "Failed", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_FAILED, expected: "Failed"}, - {desc: "None", status: v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_NONE, expected: "None"}, + {desc: "Running", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_RUNNING), expected: "Running"}, + {desc: "Progressing", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_PROGRESSING), expected: "Progressing"}, + {desc: "Idle", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_IDLE), expected: "Idle"}, + {desc: "Failed", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_FAILED), expected: "Failed"}, + {desc: "None", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_NONE), expected: "None"}, } for _, tt := range tests { diff --git a/qa.json b/qa.json deleted file mode 100644 index 50f460b3f..000000000 --- a/qa.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "alb_custom _endpoint": "", - "alb_waf_custom_endpoint": "", - "allowed_url_domain": "stackit.cloud", - "assume_yes": false, - "async": false, - "authorization_custom_endpoint": "", - "cdn_custom_endpoint": "", - "dns_custom_endpoint": "", - "edge_custom_endpoint": "", - "git_custom_endpoint": "", - "iaas_custom_endpoint": "", - "identity_provider_custom_client_id": "", - "identity_provider_custom_well_known_configuration": "https://accounts.qa.stackit.cloud/.well-known/openid-configuration", - "intake_custom_endpoint": "", - "kms_custom_endpoint": "", - "load_balancer_custom_endpoint": "", - "logme_custom_endpoint": "", - "logs_custom_endpoint": "", - "mariadb_custom_endpoint": "", - "mongodbflex_custom_endpoint": "", - "object_storage_custom_endpoint": "", - "observability_custom_endpoint": "", - "opensearch_custom_endpoint": "", - "output_format": "", - "postgresflex_custom_endpoint": "", - "project_id": "", - "rabbitmq_custom_endpoint": "", - "redis_custom_endpoint": "", - "region": "eu01", - "resource_manager_custom_endpoint": "", - "runcommand_custom_endpoint": "", - "sca_custom_endpoint": "https://sca.api.eu01.qa.stackit.cloud", - "secrets_manager_custom_endpoint": "", - "serverbackup_custom_endpoint": "", - "serverosupdate_custom_endpoint": "", - "service_account_custom_endpoint": "", - "service_enablement_custom_endpoint": "", - "session_time_limit": "12h", - "sfs_custom_endpoint": "", - "ske_custom_endpoint": "", - "sqlserverflex_custom_endpoint": "", - "token_custom_endpoint": "", - "valkey_custom_endpoint": "", - "verbosity": "info", - "vpn_custom_endpoint": "" -} \ No newline at end of file From cafa15820066fcabfe7b280ab716c1c7f19964d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jes=C3=BAs=20Padr=C3=B3n=20Hern=C3=A1ndez?= Date: Sun, 4 Oct 2026 21:04:19 +0100 Subject: [PATCH 11/11] fix linter --- .../sca/application/create/create_test.go | 2 - .../alpha/sca/application/delete/delete.go | 2 +- .../sca/application/describe/describe.go | 13 +++--- .../generate-payload/generate_payload_test.go | 43 ------------------- .../alpha/sca/application/update/update.go | 7 +-- 5 files changed, 9 insertions(+), 58 deletions(-) diff --git a/internal/cmd/alpha/sca/application/create/create_test.go b/internal/cmd/alpha/sca/application/create/create_test.go index e711ddf84..799023313 100644 --- a/internal/cmd/alpha/sca/application/create/create_test.go +++ b/internal/cmd/alpha/sca/application/create/create_test.go @@ -271,7 +271,6 @@ func TestParseInput(t *testing.T) { { desc: "invalid max instances", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - // scalingTypeFlag.Set() flagValues[scalingTypeFlag.Name()] = scalingTypeAuto delete(flagValues, instancesFlag) flagValues[minInstancesFlag] = "2" @@ -283,7 +282,6 @@ func TestParseInput(t *testing.T) { { desc: "missing rps and concurrency if autoscaling is enabled", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - // scalingTypeFlag.Set() flagValues[scalingTypeFlag.Name()] = scalingTypeAuto delete(flagValues, instancesFlag) flagValues[minInstancesFlag] = "2" diff --git a/internal/cmd/alpha/sca/application/delete/delete.go b/internal/cmd/alpha/sca/application/delete/delete.go index f72a32638..1ca21a672 100644 --- a/internal/cmd/alpha/sca/application/delete/delete.go +++ b/internal/cmd/alpha/sca/application/delete/delete.go @@ -68,7 +68,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { environmentLabel, err := utils.GetEnvironmentName(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID) if err != nil { - params.Printer.Debug(print.ErrorLevel, "get environemnt name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get environment name: %v", err) environmentLabel = model.EnvironmentID } diff --git a/internal/cmd/alpha/sca/application/describe/describe.go b/internal/cmd/alpha/sca/application/describe/describe.go index aa5291b37..bb6d23153 100644 --- a/internal/cmd/alpha/sca/application/describe/describe.go +++ b/internal/cmd/alpha/sca/application/describe/describe.go @@ -98,7 +98,7 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic table.AddRow("STATE", scautils.ApplicationStateToStr(application.GetStopped())) table.AddSeparator() if application.RuntimeStatus != nil { - table.AddRow("AVAILABE INSTANCES", len(application.GetRuntimeStatus().Instances)) + table.AddRow("AVAILABLE INSTANCES", len(application.GetRuntimeStatus().Instances)) table.AddSeparator() } table.AddRow("PUBLIC INGRESS", application.GetNetwork().PublicIngress) @@ -132,7 +132,7 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic environmentVarTables := []tables.Table{} for _, c := range application.Containers { - envVarTable := buildEnvVarsTable(c) + envVarTable := buildEnvVarsTable(&c) if envVarTable != nil { environmentVarTables = append(environmentVarTables, *envVarTable) } @@ -140,9 +140,9 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic if len(c.Command) > 0 { commands = strings.Join(c.Command, " ") } - args := "-" + cArgs := "-" if len(c.Command) > 0 { - args = strings.Join(c.Args, " ") + cArgs = strings.Join(c.Args, " ") } cpu := "-" @@ -161,7 +161,7 @@ func outputResult(p *print.Printer, outputFormat string, application *sca.Applic cpu, memory, commands, - args, + cArgs, ) } tablesToDisplay = append(tablesToDisplay, containersTable) @@ -233,7 +233,6 @@ func buildScalingRulesTable(rules []sca.ScaleRule) tables.Table { } for _, p := range rule.GetCustomRule().Parameters { params = append(params, fmt.Sprintf("%s: %s", p.Name, p.Value)) - } parameters = strings.Join(params, "\n") sm := make([]string, 0, len(rule.GetCustomRule().SecretsMapping)) @@ -257,7 +256,7 @@ func buildScalingRulesTable(rules []sca.ScaleRule) tables.Table { return table } -func buildEnvVarsTable(container sca.Container) *tables.Table { +func buildEnvVarsTable(container *sca.Container) *tables.Table { if len(container.EnvironmentVariables) == 0 { return nil } diff --git a/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go b/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go index 21436dc0e..e580da129 100644 --- a/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go +++ b/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go @@ -1,13 +1,9 @@ package generatepayload import ( - "context" "testing" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - sca "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" @@ -17,9 +13,6 @@ const ( testRegion = "eu01" ) -var testClient = &sca.APIClient{DefaultAPI: &sca.DefaultAPIService{}} -var testCtx = context.Background() - var ( testProjectID = uuid.NewString() testEnvironmentID = uuid.NewString() @@ -59,14 +52,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func fixtureRequest(mods ...func(request *sca.ApiGetApplicationRequest)) sca.ApiGetApplicationRequest { - request := testClient.DefaultAPI.GetApplication(testCtx, testProjectID, testEnvironmentID, testApplicationID) - for _, mod := range mods { - mod(&request) - } - return request -} - func TestParseInput(t *testing.T) { tests := []struct { desc string @@ -140,31 +125,3 @@ func TestParseInput(t *testing.T) { }) } } - -func TestBuildRequest(t *testing.T) { - tests := []struct { - description string - model *inputModel - expectedRequest sca.ApiGetApplicationRequest - }{ - { - 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, sca.DefaultAPIService{}), - ) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } - }) - } -} diff --git a/internal/cmd/alpha/sca/application/update/update.go b/internal/cmd/alpha/sca/application/update/update.go index 2566a0599..30cd47101 100644 --- a/internal/cmd/alpha/sca/application/update/update.go +++ b/internal/cmd/alpha/sca/application/update/update.go @@ -89,7 +89,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { `Update the container image of a SCA application with ID "xxx" from an environment with ID "yyy"`, "$ stackit alpha sca application update xxx --image new-image --environment-id yyy"), ), - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(cmd *cobra.Command, _ []string) error { updateFlags := []string{ imageFlag, publicFlag, @@ -153,9 +153,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } } - outputResult(params.Printer, model, resp) - - return nil + return outputResult(params.Printer, model, resp) }, } @@ -213,7 +211,6 @@ func containersFromInput(containers []sca.Container, model *inputModel) []sca.Co Value: val, }) } - } if updateContainer {