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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 +}