diff --git a/go.mod b/go.mod index 4cb19102d..276ed75b7 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.15.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.1 + github.com/stackitcloud/stackit-sdk-go/services/sca v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.19.1 github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1 github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.5.5 diff --git a/go.sum b/go.sum index 29912e581..7b076e157 100644 --- a/go.sum +++ b/go.sum @@ -658,6 +658,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.10.1 h1:+0dl8HjYKEdhUZEr/7WcugPhNoZzf9PBnsQWJBDLtaY= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.1/go.mod h1:fQdqal+Jduk/DY5UUHCefHn5wsRN6uROEFA/O7HObig= +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.19.1 h1:Mym5vqa3YaF42LbqlVXDFYf/evsNU76l0wpUPGEu+0U= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.19.1/go.mod h1:oFbKTkCFtp/RVjTeuv5caSuuEnf3/JG8zRDB4nbLYes= github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1 h1:lU6kQgv2tWLKDkhBRhS3v9BSFoUwHAcUOkHEa52kPVA= diff --git a/internal/cmd/alpha/alpha.go b/internal/cmd/alpha/alpha.go new file mode 100644 index 000000000..40e94b74a --- /dev/null +++ b/internal/cmd/alpha/alpha.go @@ -0,0 +1,40 @@ +package alpha + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "alpha", + Short: "Contains alpha STACKIT CLI commands", + Long: fmt.Sprintf("%s\n%s", + "Contains beta STACKIT CLI commands.", + "The commands under this group are still in an alpha state, and functionality may be incomplete or have breaking changes."), + Args: args.NoArgs, + Run: utils.CmdHelp, + Example: examples.Build( + examples.NewExample( + "See the currently available beta commands", + "$ stackit alpha --help"), + examples.NewExample( + "Execute a alpha command", + "$ stackit alpha MY_COMMAND"), + ), + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(sca.NewCmd(params)) +} diff --git a/internal/cmd/alpha/sca/application/application.go b/internal/cmd/alpha/sca/application/application.go new file mode 100644 index 000000000..17391d5e6 --- /dev/null +++ b/internal/cmd/alpha/sca/application/application.go @@ -0,0 +1,40 @@ +package application + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/create" + createfrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/create-from-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/update" + updatefrompayload "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application/update-from-payload" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +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)) + 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/alpha/sca/application/create-from-payload/create_from_payload.go b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go new file mode 100644 index 000000000..65ab48a6c --- /dev/null +++ b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload.go @@ -0,0 +1,168 @@ +package createfrompayload + +import ( + "context" + "encoding/json" + "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" + "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" +) + +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: fmt.Sprintf("%s\n%s\n%s", + "Create a STACKIT Container Applications (SCA) application from payload.", + `The payload can be provided as a JSON string or a file path prefixed with "@".`, + "See https://docs.api.stackit.cloud/documentation/sca/version/v1alpha#tag/Applications/operation/Applications_CreateApplication for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a SCA application using an API payload sourced from the file "./payload.json"`, + "$ stackit alpha sca cluster create-from-payload --name application-name --payload @./payload.json"), + examples.NewExample( + `Create a SCA application using an API payload provided as a JSON string`, + `$ stackit alpha sca cluster create-from-payload --name application-name --payload "{...}"`), + examples.NewExample( + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit alpha sca application generate-payload --file-path ./payload.json`, + ``, + `$ stackit alpha sca application create-from-payload --name application-name --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, nil) + 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) + } + } + + return outputResult(params.Printer, model, resp) + }, + } + + 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) error { + if application == nil { + return fmt.Errorf("create application response is empty") + } + + return p.OutputResult(model.OutputFormat, application, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") + cmd.Flags().String(nameFlag, "", "Application display name") + 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, _ []string) (*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("encode payload: %w", err) + } + } + + if payload == nil { + return nil, &errors.FlagValidationError{ + Flag: payloadFlag, + Details: "Received empty payload", + } + } + + 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/alpha/sca/application/create-from-payload/create_from_payload_test.go b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go new file mode 100644 index 000000000..3a5f17f9b --- /dev/null +++ b/internal/cmd/alpha/sca/application/create-from-payload/create_from_payload_test.go @@ -0,0 +1,195 @@ +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, + }, + { + desc: "empty json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "{}" + }), + 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/alpha/sca/application/create/create.go b/internal/cmd/alpha/sca/application/create/create.go new file mode 100644 index 000000000..7b7bfd576 --- /dev/null +++ b/internal/cmd/alpha/sca/application/create/create.go @@ -0,0 +1,387 @@ +package create + +import ( + "context" + "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" + "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/services/sca/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + environmentIDFlag = "environment-id" + nameFlag = "name" + // Container config + containerNameFlag = "container-name" + 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" + + scalingTypeManual = "manual" + scalingTypeAuto = "auto" + + defaultPublic = true + defaultPort = 8080 + defaultCPU = 1000 + defaultMemory = 1024 + defaultInstances = 1 + defaultContainerName = "container-1" +) + +var scalingTypeFlag = flags.StringEnumFlag( + "scaling-type", + []string{scalingTypeManual, scalingTypeAuto}, + "Scaling type,", + flags.StringEnumDefaultValue(scalingTypeManual), +) + +type inputModel struct { + *globalflags.GlobalFlagModel + EnvironmentID string + Name string + ContainerName string + Image string + 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 + Public bool + ContainerExternalPort int32 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a SCA application", + Long: "Create a STACKIT Container Applications (SCA) application.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" for an environment with ID "yyy"`, + "$ stackit alpha sca application create --name application-name --image my-image --environment-id yyy"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" with 2 instances`, + "$ stackit alpha sca application create --name application-name --image my-image --instances 2"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" exposing port 8888 of the container`, + "$ stackit alpha sca application create --name application-name --image my-image --external-port 8888"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" disabling public networking`, + "$ stackit alpha sca application create --name application-name --image my-image --public=false"), + examples.NewExample( + `Create a SCA application with name "application-name" and image "my-image" and environment variables ENV1=value1 and ENV2=value2`, + "$ stackit alpha sca application create --name application-name --image my-image --environment-vars ENV1=value1,ENV2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + 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("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) + } + } + + return outputResult(params.Printer, model, resp) + }, + } + + configureFlags(cmd) + 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 { + var scaleRules []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, + }, + }) + } + + return scaleRules +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sca.APIClient) sca.ApiCreateApplicationRequest { + network := sca.Network{ + PublicIngress: model.Public, + Port: &model.ContainerExternalPort, + } + + envVars := utils.EnvironmentVariablesFromMap(model.EnvironmentVars) + + container := sca.Container{ + 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: buildScalingConfig(model), + 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) error { + if application == nil { + return fmt.Errorf("create application response is empty") + } + + return p.OutputResult(model.OutputFormat, application, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), environmentIDFlag, "Environment ID (uses default environment if not set)") + cmd.Flags().String(nameFlag, "", "Application display name") + // container + cmd.Flags().String(containerNameFlag, defaultContainerName, "Container name") + cmd.Flags().String(imageFlag, "", "Container image") + cmd.Flags().Int32(cpuFlag, defaultCPU, "The dedicated virtual CPU processing power allocated per container instance") + cmd.Flags().Int32(memoryFlag, defaultMemory, "The total amount of memory (RAM) allocated per container instance") + 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, 0, "The maximum number of application instances (if autoscaling is enabled)") + cmd.Flags().Int32(rpsFlag, 0, "Target number of requests per second to trigger autoscaling (if autoscaling is enabled)") + cmd.Flags().Int32(concurrencyFlag, 0, "Target number of in-flight requests to trigger autoscaling (if autoscaling is enabled)") + cmd.Flags().Bool(scaleToZeroFlag, false, "Enable scale to zero (if autoscaling is enabled)") + // 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)) +} + +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 + } + + if err := validateScalingInput(cmd, scalingType); err != nil { + return nil, err + } + + 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", + } + } + + 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 := minInstances + if i := flags.FlagToInt32Pointer(p, cmd, maxInstancesFlag); i != nil { + maxInstances = *i + } + if maxInstances < minInstances || maxInstances > 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 <= 0 || cpu%1000 != 0 { + return nil, &errors.FlagValidationError{ + Flag: cpuFlag, + Details: "must be a valid value 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 + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + ContainerName: flags.FlagWithDefaultToStringValue(p, cmd, containerNameFlag), + Image: flags.FlagToStringValue(p, cmd, imageFlag), + CPU: cpu, + Memory: flags.FlagWithDefaultToInt32Value(p, cmd, memoryFlag), + 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/alpha/sca/application/create/create_test.go b/internal/cmd/alpha/sca/application/create/create_test.go new file mode 100644 index 000000000..799023313 --- /dev/null +++ b/internal/cmd/alpha/sca/application/create/create_test.go @@ -0,0 +1,330 @@ +package create + +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() +) + +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", + containerNameFlag: "my-container", + imageFlag: "test-image", + externalPortFlag: "8888", + cpuFlag: "2000", + memoryFlag: "2048", + instancesFlag: "2", + publicFlag: "true", + scalingTypeFlag.Name(): scalingTypeManual, + 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", + ContainerName: "my-container", + Image: "test-image", + ContainerExternalPort: 8888, + ScalingType: scalingTypeManual, + Public: true, + CPU: 2000, + Memory: 2048, + Instances: 2, + MinInstances: 1, + MaxInstances: 1, + 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: new(int32(8888)), + }, + Scaling: sca.Scaling{ + Type: sca.SCALINGTYPE_SCALING_TYPE_MANUAL, + ManualScaling: &sca.ManualScaling{ + Instances: 2, + }, + }, + Containers: []sca.Container{{ + Name: "container-1", + Image: "test-image", + Cpu: new(int32(2000)), + Memory: new(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, + }, + { + desc: "invalid min instances", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[minInstancesFlag] = "-2" + }), + isValid: false, + }, + { + desc: "valid autoscaling config", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[maxInstancesFlag] = "3" + flagValues[rpsFlag] = "5" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ScalingType = scalingTypeAuto + model.Instances = defaultInstances + model.MinInstances = 2 + model.MaxInstances = 3 + model.RPS = 5 + }), + isValid: true, + }, + { + desc: "set min instances as max instances if not defined", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + delete(flagValues, maxInstancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[rpsFlag] = "5" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ScalingType = scalingTypeAuto + model.Instances = defaultInstances + model.MinInstances = 2 + model.MaxInstances = 2 + model.RPS = 5 + }), + isValid: true, + }, + { + desc: "invalid max instances", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[maxInstancesFlag] = "1" + flagValues[rpsFlag] = "5" + }), + isValid: false, + }, + { + desc: "missing rps and concurrency if autoscaling is enabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[scalingTypeFlag.Name()] = scalingTypeAuto + delete(flagValues, instancesFlag) + flagValues[minInstancesFlag] = "2" + flagValues[maxInstancesFlag] = "5" + }), + 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/alpha/sca/application/delete/delete.go b/internal/cmd/alpha/sca/application/delete/delete.go new file mode 100644 index 000000000..1ca21a672 --- /dev/null +++ b/internal/cmd/alpha/sca/application/delete/delete.go @@ -0,0 +1,133 @@ +package delete + +import ( + "context" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +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 Container Applications (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 alpha 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{} + } + + applicationLabel, err := utils.GetApplicationName(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID, model.ApplicationID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get application name: %v", err) + applicationLabel = model.ApplicationID + } + + environmentLabel, err := utils.GetEnvironmentName(ctx, apiClient.DefaultAPI, model.ProjectId, model.EnvironmentID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get environment name: %v", err) + environmentLabel = model.EnvironmentID + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the application %q from environment %q and product %q?", applicationLabel, environmentLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete application: %w", err) + } + + params.Printer.Info("Deleted application %q from environment %q\n", applicationLabel, environmentLabel) + + 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 +} + +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/alpha/sca/application/delete/delete_test.go b/internal/cmd/alpha/sca/application/delete/delete_test.go new file mode 100644 index 000000000..fac2244f1 --- /dev/null +++ b/internal/cmd/alpha/sca/application/delete/delete_test.go @@ -0,0 +1,176 @@ +package delete + +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() +) + +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/alpha/sca/application/describe/describe.go b/internal/cmd/alpha/sca/application/describe/describe.go new file mode 100644 index 000000000..bb6d23153 --- /dev/null +++ b/internal/cmd/alpha/sca/application/describe/describe.go @@ -0,0 +1,279 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "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" + "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" +) + +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 Container Applications (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 alpha sca application describe xxx --environment-id yyy"), + examples.NewExample( + `Get details of all SCA application with ID "xxx" from an environment with ID "yyy" in JSON format`, + "$ stackit alpha 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("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.GetRuntimeStatus().CurrentStatus)) + table.AddSeparator() + table.AddRow("STATE", scautils.ApplicationStateToStr(application.GetStopped())) + table.AddSeparator() + if application.RuntimeStatus != nil { + table.AddRow("AVAILABLE INSTANCES", len(application.GetRuntimeStatus().Instances)) + table.AddSeparator() + } + table.AddRow("PUBLIC INGRESS", application.GetNetwork().PublicIngress) + if application.Network.Port != nil { + table.AddRow("PUBLIC PORT", *application.GetNetwork().Port) + } + if len(application.GetRuntimeStatus().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", "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, " ") + } + cArgs := "-" + if len(c.Command) > 0 { + cArgs = strings.Join(c.Args, " ") + } + + cpu := "-" + if c.Cpu != nil { + cpu = string(*c.Cpu) + } + + memory := "-" + if c.Memory != nil { + memory = string(*c.Memory) + } + + containersTable.AddRow( + c.Name, + c.Image, + cpu, + memory, + commands, + cArgs, + ) + } + tablesToDisplay = append(tablesToDisplay, containersTable) + err := tables.DisplayTables(p, append(tablesToDisplay, environmentVarTables...)) + 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 (if not set uses default environment)") +} + +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 +} + +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/alpha/sca/application/describe/describe_test.go b/internal/cmd/alpha/sca/application/describe/describe_test.go new file mode 100644 index 000000000..4b6d867a3 --- /dev/null +++ b/internal/cmd/alpha/sca/application/describe/describe_test.go @@ -0,0 +1,159 @@ +package describe + +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() +) + +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/alpha/sca/application/generate-payload/generate_payload.go b/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go new file mode 100644 index 000000000..741b623b0 --- /dev/null +++ b/internal/cmd/alpha/sca/application/generate-payload/generate_payload.go @@ -0,0 +1,149 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "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" + "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" +) + +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/update SCA applications", + Long: fmt.Sprintf("%s\n%s", + "Generates a JSON payload with values to be used as --payload input for application creation or update.", + "See https://docs.api.stackit.cloud/documentation/sca/version/v1alpha#tag/Applications/operation/Applications_CreateApplication for information regarding the payload structure.", + ), + 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 alpha sca application generate-payload --file-path ./payload.json`, + ``, + `$ stackit alpha sca application create-from-payload --name application-name --payload @./payload.json`, + ), + examples.NewExample( + `Generate a payload with values of an application, and adapt it with custom values for the different configuration options`, + `$ stackit alpha sca application generate-payload --application-id xxx --file-path ./payload.json`, + ``, + `$ stackit alpha sca application update-from-payload --payload @./payload.json`, + ), + examples.NewExample( + `Generate a payload with values of an application, and preview it in the terminal`, + `$ stackit alpha sca application generate-payload --application-id xxx`, + ), + ), + 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 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/alpha/sca/application/generate-payload/generate_payload_test.go b/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..e580da129 --- /dev/null +++ b/internal/cmd/alpha/sca/application/generate-payload/generate_payload_test.go @@ -0,0 +1,127 @@ +package generatepayload + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +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 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.ProjectId = "" + model.Region = "" + model.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) + }) + } +} diff --git a/internal/cmd/alpha/sca/application/list/list.go b/internal/cmd/alpha/sca/application/list/list.go new file mode 100644 index 000000000..bf7312846 --- /dev/null +++ b/internal/cmd/alpha/sca/application/list/list.go @@ -0,0 +1,168 @@ +package list + +import ( + "context" + "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" + "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" +) + +const ( + limitFlag = "limit" + environmentIDFlag = "environment-id" +) + +type listApplicationsRequest interface { + Execute() (*sca.ListApplicationsResponse, error) +} + +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 Container Applications (SCA) applications.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all SCA applications`, + "$ stackit alpha sca application list"), + examples.NewExample( + `List all SCA applications from environment with ID "xxx"`, + "$ stackit alpha sca application list --environment-id xxx"), + examples.NewExample( + `List all SCA applications in JSON format`, + "$ stackit alpha sca application list --output-format json"), + examples.NewExample( + `List up to 10 SCA applications`, + "$ stackit alpha sca application list --limit 10"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + 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] + } + + 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, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: flags.FlagToStringValue(p, cmd, environmentIDFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +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 == "" { + return apiClient.DefaultAPI.ListProjectApplications(ctx, model.ProjectId) + } + + return apiClient.DefaultAPI.ListApplications(ctx, model.ProjectId, model.EnvironmentID) +} + +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.Status), + scautils.ApplicationStateToStr(a.GetStopped()), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/alpha/sca/application/list/list_test.go b/internal/cmd/alpha/sca/application/list/list_test.go new file mode 100644 index 000000000..ecf15e095 --- /dev/null +++ b/internal/cmd/alpha/sca/application/list/list_test.go @@ -0,0 +1,145 @@ +package list + +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() +) + +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/alpha/sca/application/update-from-payload/update-from-payload.go b/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go new file mode 100644 index 000000000..97bd8f9e5 --- /dev/null +++ b/internal/cmd/alpha/sca/application/update-from-payload/update-from-payload.go @@ -0,0 +1,161 @@ +package updatefrompayload + +import ( + "context" + "encoding/json" + "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" + "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" +) + +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 Container Applications (SCA) application from payload.", + Args: args.SingleArg(applicationIDArg, nil), + Example: examples.Build( + examples.NewExample( + `Update a SCA application using an API payload sourced from the file "./payload.json"`, + "$ stackit alpha sca application update-from-payload my-application-id --payload @./payload.json"), + examples.NewExample( + `Update a SCA application using an API payload provided as a JSON string`, + `$ stackit alpha sca application update-from-payload my-application-id --payload "{...}"`), + examples.NewExample( + `Generate a payload with the current values of an application, and adapt it with custom values for the different configuration options`, + `$ stackit alpha sca application generate-payload --application-id application-id > ./payload.json`, + ``, + `$ stackit alpha sca application update-from-payload application-id --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + 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) + } + } + + if payload == nil { + return nil, &errors.FlagValidationError{ + Flag: payloadFlag, + Details: "Received empty payload", + } + } + + payload.AdditionalProperties = nil + + model := inputModel{ + GlobalFlagModel: globalFlags, + EnvironmentID: environmentID, + ApplicationID: applicationID, + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/alpha/sca/application/update-from-payload/update_from_payload_test.go b/internal/cmd/alpha/sca/application/update-from-payload/update_from_payload_test.go new file mode 100644 index 000000000..c2298b92f --- /dev/null +++ b/internal/cmd/alpha/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/alpha/sca/application/update/update.go b/internal/cmd/alpha/sca/application/update/update.go new file mode 100644 index 000000000..30cd47101 --- /dev/null +++ b/internal/cmd/alpha/sca/application/update/update.go @@ -0,0 +1,480 @@ +package update + +import ( + "context" + "fmt" + "slices" + + "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" + "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/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + applicationIDArg = "APPLICATION_ID" + environmentIDFlag = "environment-id" + imageFlag = "image" + publicFlag = "public" + externalPortFlag = "external-port" + cpuFlag = "cpu" + memoryFlag = "memory" + 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 + 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 + Stopped *bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update a SCA application", + Long: "Update a STACKIT Container Applications (SCA) application.", + Args: args.SingleArg(applicationIDArg, nil), + Example: examples.Build( + examples.NewExample( + `Update the number of instances of a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit alpha sca application update xxx --instances 2 --environment-id yyy"), + examples.NewExample( + `Update the container image of a SCA application with ID "xxx" from an environment with ID "yyy"`, + "$ stackit alpha sca application update xxx --image new-image --environment-id yyy"), + ), + PreRunE: func(cmd *cobra.Command, _ []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() + + 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, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + 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) + } + } + + return outputResult(params.Printer, model, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +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 + containers[0].Image = model.Image + } + if model.CPU != nil { + updateContainer = true + containers[0].Cpu = model.CPU + } + if model.Memory != nil { + updateContainer = true + containers[0].Memory = model.Memory + } + + 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 + 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 containers + } + + return nil +} + +func networkFromInput(model *inputModel) *sca.Network { + if model.Public == nil && model.ContainerExternalPort == nil { + return nil + } + + 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, 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(current.Containers, model) + payload.Network = networkFromInput(model) + + 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 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) { + 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 + } + + 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), + 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) + return &model, nil +} + +func outputResult(p *print.Printer, model *inputModel, application *sca.Application) error { + if application == nil { + return fmt.Errorf("update application response is empty") + } + + return p.OutputResult(model.OutputFormat, application, func() error { + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + + p.Outputf("%s application for environment %s. Application ID: %s\n", operationState, application.GetEnvironmentId(), application.GetId()) + return nil + }) +} diff --git a/internal/cmd/alpha/sca/sca.go b/internal/cmd/alpha/sca/sca.go new file mode 100644 index 000000000..9ec84c96f --- /dev/null +++ b/internal/cmd/alpha/sca/sca.go @@ -0,0 +1,26 @@ +package sca + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/sca/application" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "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/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 5a37ee68c..57fe97095 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -56,6 +56,7 @@ const ( sfsCustomEndpointFlag = "sfs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + scaCustomEndpointFlag = "sca-custom-endpoint" ) type inputModel struct { @@ -178,6 +179,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) @@ -252,6 +254,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 ba128c62f..8e7454a4a 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -59,6 +59,7 @@ const ( logsCustomEndpointFlag = "logs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + scaCustomEndpointFlag = "sca-custom-endpoint" ) var ( @@ -111,6 +112,7 @@ type inputModel struct { LogsCustomEndpoint bool CDNCustomEndpoint bool VpnCustomEndpoint bool + ScaCustomEndpoint bool } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -261,6 +263,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 { @@ -318,6 +323,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 { @@ -366,6 +372,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 5ca73e03a..d09329351 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -51,6 +51,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) @@ -100,6 +101,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { LogsCustomEndpoint: true, CDNCustomEndpoint: true, VpnCustomEndpoint: true, + ScaCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -165,6 +167,7 @@ func TestParseInput(t *testing.T) { model.LogsCustomEndpoint = false model.CDNCustomEndpoint = false model.VpnCustomEndpoint = false + model.ScaCustomEndpoint = false }), }, { @@ -397,6 +400,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..d5e0e7931 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/alpha" "github.com/stackitcloud/stackit-cli/internal/cmd/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/pkg/types" @@ -171,6 +172,7 @@ func configureFlags(cmd *cobra.Command) error { } func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(alpha.NewCmd(params)) cmd.AddCommand(auth.NewCmd(params)) cmd.AddCommand(configCmd.NewCmd(params)) cmd.AddCommand(beta.NewCmd(params)) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index b0ad97036..e0823f28f 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -59,6 +59,7 @@ const ( LogsCustomEndpointKey = "logs_custom_endpoint" ValkeyCustomEndpointKey = "valkey_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" + SCACustomEndpointKey = "sca_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -130,6 +131,7 @@ var ConfigKeys = []string{ TokenCustomEndpointKey, ValkeyCustomEndpointKey, VPNCustomEndpointKey, + SCACustomEndpointKey, } var defaultConfigFolderPath string @@ -225,6 +227,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..be2d3c510 --- /dev/null +++ b/internal/pkg/services/sca/client/client.go @@ -0,0 +1,14 @@ +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" +) + +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..0a4b1f1e8 --- /dev/null +++ b/internal/pkg/services/sca/utils/utils.go @@ -0,0 +1,100 @@ +package utils + +import ( + "context" + "fmt" + + 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 { + if status == nil { + return "None" + } + 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 { + return "Active" + } + 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 +} + +func GetApplicationName(ctx context.Context, apiClient sca.DefaultAPI, projectId, environmentID, applicaitonID string) (string, error) { + resp, err := apiClient.GetApplication(ctx, projectId, environmentID, applicaitonID).Execute() + if err != nil { + return "", fmt.Errorf("get application: %w", err) + } + return resp.DisplayName, nil +} + +func GetEnvironmentName(ctx context.Context, apiClient sca.DefaultAPI, projectId, environmentID string) (string, error) { + resp, err := apiClient.GetEnvironment(ctx, projectId, environmentID).Execute() + if err != nil { + return "", fmt.Errorf("get environment: %w", err) + } + return resp.DisplayName, nil +} diff --git a/internal/pkg/services/sca/utils/utils_test.go b/internal/pkg/services/sca/utils/utils_test.go new file mode 100644 index 000000000..8ccb62a9a --- /dev/null +++ b/internal/pkg/services/sca/utils/utils_test.go @@ -0,0 +1,52 @@ +package utils_test + +import ( + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/sca/v1alphaapi" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sca/utils" +) + +func TestApplicationStatusToStr(t *testing.T) { + tests := []struct { + desc string + status *v1alphaapi.CurrentStatus + expected string + }{ + {desc: "Running", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_RUNNING), expected: "Running"}, + {desc: "Progressing", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_PROGRESSING), expected: "Progressing"}, + {desc: "Idle", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_IDLE), expected: "Idle"}, + {desc: "Failed", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_FAILED), expected: "Failed"}, + {desc: "None", status: new(v1alphaapi.CURRENTSTATUS_CURRENT_STATUS_NONE), expected: "None"}, + } + + for _, tt := range tests { + 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) + } + }) + } +}