From bdae505f5aeec2433fa22070b183208291855c52 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:35:39 +0200 Subject: [PATCH 01/17] chore(deps): add stackit-sdk-go/services/registry v0.1.0 --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 0269fcc6f..01aaa0487 100644 --- a/go.mod +++ b/go.mod @@ -208,6 +208,7 @@ require ( github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stackitcloud/stackit-sdk-go/services/registry v0.1.0 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.11.1 // indirect diff --git a/go.sum b/go.sum index f00f5588a..137114250 100644 --- a/go.sum +++ b/go.sum @@ -654,6 +654,8 @@ github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.5.0 h1:SAro5TWVJDDyi github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.5.0/go.mod h1:mjYkyr/A/+9rZnX5IOLEoAMFqHsRCIToZWpsYzYBwWo= github.com/stackitcloud/stackit-sdk-go/services/redis v1.6.0 h1:5UEPilI+RCuXEM8jm4ibREoZAsSAGI1+KnJ0oapye7Q= github.com/stackitcloud/stackit-sdk-go/services/redis v1.6.0/go.mod h1:/wE98YN+s1s1OcaEPt8Z2xO0Y46Gur3nXlg1AiiMNeY= +github.com/stackitcloud/stackit-sdk-go/services/registry v0.1.0 h1:Z52pSNI1o1vi6h25ZQRl26lPMXFRq8RQVEjGlNA+Pwk= +github.com/stackitcloud/stackit-sdk-go/services/registry v0.1.0/go.mod h1:9ew0RfPiwsDbd2OApdaAfTJgZyJbtKKP63NNZz3RWBQ= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.26.1 h1:6NHoXh6c/GQ7+CO03bszc09rnKCeP0dzMmyrnz6mkvs= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.26.1/go.mod h1:IsId3wbkp5oaO0JEaHZpZnbVW6KFHlAptwB+1iG3v3w= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 h1:EVeeGzvyiCN4VbInHIWXZtL8hPsVzZIdnfXjgyZzUf0= From 96c3cd6b42b0542d7e5a2151803c632f2febcd2b Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:35:42 +0200 Subject: [PATCH 02/17] feat(registry): add client configuration and helpers --- internal/pkg/config/config.go | 3 +++ internal/pkg/services/registry/client/client.go | 14 ++++++++++++++ internal/pkg/services/registry/utils/utils.go | 16 ++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 internal/pkg/services/registry/client/client.go create mode 100644 internal/pkg/services/registry/utils/utils.go diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index db2441f6d..7032b3aaa 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -59,6 +59,7 @@ const ( IntakeCustomEndpointKey = "intake_custom_endpoint" LogsCustomEndpointKey = "logs_custom_endpoint" ValkeyCustomEndpointKey = "valkey_custom_endpoint" + RegistryCustomEndpointKey = "registry_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" ProjectNameKey = "project_name" @@ -131,6 +132,7 @@ var ConfigKeys = []string{ SfsCustomEndpointKey, TokenCustomEndpointKey, ValkeyCustomEndpointKey, + RegistryCustomEndpointKey, VPNCustomEndpointKey, } @@ -227,6 +229,7 @@ func setConfigDefaults() { viper.SetDefault(LogsCustomEndpointKey, "") viper.SetDefault(CDNCustomEndpointKey, "") viper.SetDefault(ValkeyCustomEndpointKey, "") + viper.SetDefault(RegistryCustomEndpointKey, "") viper.SetDefault(VPNCustomEndpointKey, "") } diff --git a/internal/pkg/services/registry/client/client.go b/internal/pkg/services/registry/client/client.go new file mode 100644 index 000000000..e0a1e4eaf --- /dev/null +++ b/internal/pkg/services/registry/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/spf13/viper" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "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) (*registry.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.RegistryCustomEndpointKey), false, genericclient.CreateApiClient[*registry.APIClient](registry.NewAPIClient)) +} diff --git a/internal/pkg/services/registry/utils/utils.go b/internal/pkg/services/registry/utils/utils.go new file mode 100644 index 000000000..6e7136074 --- /dev/null +++ b/internal/pkg/services/registry/utils/utils.go @@ -0,0 +1,16 @@ +package utils + +import ( + "context" + "fmt" + + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" +) + +func GetArtifactoryName(ctx context.Context, apiClient registry.DefaultAPI, projectId, region, artifactoryId string) (string, error) { + resp, err := apiClient.GetArtifactory(ctx, projectId, region, artifactoryId).Execute() + if err != nil { + return "", fmt.Errorf("get artifactory: %w", err) + } + return resp.Name, nil +} From 508e17405b39aa2d7903fca7664c3b50c2ba4fcb Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:36:12 +0200 Subject: [PATCH 03/17] feat(registry): add artifactory commands and tests --- .../cmd/registry/artifactory/artifactory.go | 34 +++ .../cmd/registry/artifactory/create/create.go | 139 ++++++++++++ .../artifactory/create/create_test.go | 202 +++++++++++++++++ .../cmd/registry/artifactory/delete/delete.go | 116 ++++++++++ .../artifactory/delete/delete_test.go | 138 ++++++++++++ .../registry/artifactory/describe/describe.go | 126 +++++++++++ .../artifactory/describe/describe_test.go | 201 +++++++++++++++++ .../cmd/registry/artifactory/list/list.go | 144 ++++++++++++ .../registry/artifactory/list/list_test.go | 206 ++++++++++++++++++ .../cmd/registry/artifactory/update/update.go | 172 +++++++++++++++ .../artifactory/update/update_test.go | 202 +++++++++++++++++ internal/cmd/registry/registry.go | 26 +++ internal/cmd/root.go | 2 + 13 files changed, 1708 insertions(+) create mode 100644 internal/cmd/registry/artifactory/artifactory.go create mode 100644 internal/cmd/registry/artifactory/create/create.go create mode 100644 internal/cmd/registry/artifactory/create/create_test.go create mode 100644 internal/cmd/registry/artifactory/delete/delete.go create mode 100644 internal/cmd/registry/artifactory/delete/delete_test.go create mode 100644 internal/cmd/registry/artifactory/describe/describe.go create mode 100644 internal/cmd/registry/artifactory/describe/describe_test.go create mode 100644 internal/cmd/registry/artifactory/list/list.go create mode 100644 internal/cmd/registry/artifactory/list/list_test.go create mode 100644 internal/cmd/registry/artifactory/update/update.go create mode 100644 internal/cmd/registry/artifactory/update/update_test.go create mode 100644 internal/cmd/registry/registry.go diff --git a/internal/cmd/registry/artifactory/artifactory.go b/internal/cmd/registry/artifactory/artifactory.go new file mode 100644 index 000000000..bfcc50a14 --- /dev/null +++ b/internal/cmd/registry/artifactory/artifactory.go @@ -0,0 +1,34 @@ +package artifactory + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory/update" + "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: "artifactory", + Short: "Provides functionality for Container Registry artifactories", + Long: "Provides functionality for Container Registry artifactories.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/registry/artifactory/create/create.go b/internal/cmd/registry/artifactory/create/create.go new file mode 100644 index 000000000..c24665b07 --- /dev/null +++ b/internal/cmd/registry/artifactory/create/create.go @@ -0,0 +1,139 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/registry/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + nameFlag = "name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Container Registry artifactory", + Long: "Creates a Container Registry artifactory.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an artifactory with name "my-registry"`, + "$ stackit registry artifactory create --name my-registry"), + ), + 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 + } + + 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 create an artifactory for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create artifactory: %w", err) + } + artifactoryId := resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating artifactory", func() error { + _, err = wait.CreateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, artifactoryId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for artifactory creation: %w", err) + } + } + + return outputResult(params.Printer, model, projectLabel, artifactoryId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "Artifactory name") + + err := flags.MarkFlagsRequired(cmd, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiCreateArtifactoryRequest { + req := apiClient.DefaultAPI.CreateArtifactory(ctx, model.ProjectId, model.Region) + return req.CreateArtifactoryPayload(registry.CreateArtifactoryPayload{ + Name: model.Name, + }) +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel, artifactoryId string, resp *registry.Artifactory) error { + if model == nil { + return fmt.Errorf("no model passed") + } + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("no response defined") + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s artifactory for project %q. Artifactory ID: %s\n", operationState, projectLabel, artifactoryId) + return nil + }) +} diff --git a/internal/cmd/registry/artifactory/create/create_test.go b/internal/cmd/registry/artifactory/create/create_test.go new file mode 100644 index 000000000..9812f1e51 --- /dev/null +++ b/internal/cmd/registry/artifactory/create/create_test.go @@ -0,0 +1,202 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() + testName = "my-registry" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: testName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: testName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *registry.ApiCreateArtifactoryRequest)) registry.ApiCreateArtifactoryRequest { + request := testClient.DefaultAPI.CreateArtifactory(testCtx, testProjectId, testRegion). + CreateArtifactoryPayload(registry.CreateArtifactoryPayload{ + Name: testName, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest registry.ApiCreateArtifactoryRequest + }{ + { + 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, registry.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + id string + resp *registry.Artifactory + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty response", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: true, + }, + { + name: "created successfully sync", + args: args{ + model: fixtureInputModel(), + projectLabel: "example-project", + id: "xxx", + resp: ®istry.Artifactory{ + Id: "xxx", + }, + }, + wantErr: false, + }, + { + name: "created successfully async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "example-project", + id: "xxx", + resp: ®istry.Artifactory{ + Id: "xxx", + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.projectLabel, tt.args.id, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/registry/artifactory/delete/delete.go b/internal/cmd/registry/artifactory/delete/delete.go new file mode 100644 index 000000000..1c429ecd2 --- /dev/null +++ b/internal/cmd/registry/artifactory/delete/delete.go @@ -0,0 +1,116 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api/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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/registry/client" + registryUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/registry/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + artifactoryIdArg = "ARTIFACTORY_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ArtifactoryId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", artifactoryIdArg), + Short: "Deletes a Container Registry artifactory", + Long: "Deletes a Container Registry artifactory.", + Args: args.SingleArg(artifactoryIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an artifactory with ID "xxx"`, + "$ stackit registry artifactory delete xxx"), + ), + 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 + } + + artifactoryLabel, err := registryUtils.GetArtifactoryName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ArtifactoryId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get artifactory name: %v", err) + artifactoryLabel = model.ArtifactoryId + } + + prompt := fmt.Sprintf("Are you sure you want to delete artifactory %q? (This cannot be undone)", artifactoryLabel) + 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 artifactory: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting artifactory", func() error { + _, err = wait.DeleteArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ArtifactoryId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for artifactory deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s artifactory %q\n", operationState, artifactoryLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + artifactoryId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ArtifactoryId: artifactoryId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiDeleteArtifactoryRequest { + return apiClient.DefaultAPI.DeleteArtifactory(ctx, model.ProjectId, model.Region, model.ArtifactoryId) +} diff --git a/internal/cmd/registry/artifactory/delete/delete_test.go b/internal/cmd/registry/artifactory/delete/delete_test.go new file mode 100644 index 000000000..fb9e257fb --- /dev/null +++ b/internal/cmd/registry/artifactory/delete/delete_test.go @@ -0,0 +1,138 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() + testArtifactoryId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ArtifactoryId: testArtifactoryId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *registry.ApiDeleteArtifactoryRequest)) registry.ApiDeleteArtifactoryRequest { + request := testClient.DefaultAPI.DeleteArtifactory(testCtx, testProjectId, testRegion, testArtifactoryId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id missing", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + 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 registry.ApiDeleteArtifactoryRequest + }{ + { + 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, registry.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/registry/artifactory/describe/describe.go b/internal/cmd/registry/artifactory/describe/describe.go new file mode 100644 index 000000000..def54a318 --- /dev/null +++ b/internal/cmd/registry/artifactory/describe/describe.go @@ -0,0 +1,126 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/registry/client" + "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 ( + artifactoryIdArg = "ARTIFACTORY_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ArtifactoryId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", artifactoryIdArg), + Short: "Shows details of a Container Registry artifactory", + Long: "Shows details of a Container Registry artifactory.", + Args: args.SingleArg(artifactoryIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an artifactory with ID "xxx"`, + "$ stackit registry artifactory describe xxx"), + examples.NewExample( + `Get details of an artifactory with ID "xxx" in JSON format`, + "$ stackit registry artifactory describe xxx --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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read artifactory: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + artifactoryId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ArtifactoryId: artifactoryId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiGetArtifactoryRequest { + return apiClient.DefaultAPI.GetArtifactory(ctx, model.ProjectId, model.Region, model.ArtifactoryId) +} + +func outputResult(p *print.Printer, outputFormat string, artifactory *registry.Artifactory) error { + return p.OutputResult(outputFormat, artifactory, func() error { + if artifactory == nil { + return fmt.Errorf("no artifactory passed") + } + + table := tables.NewTable() + table.AddRow("ID", artifactory.Id) + table.AddSeparator() + table.AddRow("NAME", artifactory.Name) + table.AddSeparator() + table.AddRow("STATE", string(artifactory.State)) + table.AddSeparator() + table.AddRow("URL", artifactory.Url) + table.AddSeparator() + table.AddRow("CONSUMED QUOTA BYTES", artifactory.ConsumedQuotaBytes) + table.AddSeparator() + table.AddRow("CREATED AT", artifactory.CreateTime.String()) + if artifactory.Proxy != nil { + table.AddSeparator() + table.AddRow("PROXY URL", artifactory.Proxy.Url) + if artifactory.Proxy.Description != nil && *artifactory.Proxy.Description != "" { + table.AddSeparator() + table.AddRow("PROXY DESCRIPTION", *artifactory.Proxy.Description) + } + table.AddSeparator() + table.AddRow("PROXY REGISTRY TYPE", artifactory.Proxy.RegistryType) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/registry/artifactory/describe/describe_test.go b/internal/cmd/registry/artifactory/describe/describe_test.go new file mode 100644 index 000000000..bd26e096c --- /dev/null +++ b/internal/cmd/registry/artifactory/describe/describe_test.go @@ -0,0 +1,201 @@ +package describe + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() + testArtifactoryId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ArtifactoryId: testArtifactoryId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *registry.ApiGetArtifactoryRequest)) registry.ApiGetArtifactoryRequest { + request := testClient.DefaultAPI.GetArtifactory(testCtx, testProjectId, testRegion, testArtifactoryId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id missing", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + 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 registry.ApiGetArtifactoryRequest + }{ + { + 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, registry.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + artifactory *registry.Artifactory + } + desc := "docker hub mirror" + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "single item", + args: args{ + artifactory: ®istry.Artifactory{ + Id: testArtifactoryId, + Name: "my-artifactory", + State: registry.ARTIFACTORYSTATE_ACTIVE, + Url: "https://example.com", + ConsumedQuotaBytes: 1024, + CreateTime: time.Now(), + }, + }, + wantErr: false, + }, + { + name: "with proxy", + args: args{ + artifactory: ®istry.Artifactory{ + Id: testArtifactoryId, + Name: "my-artifactory", + State: registry.ARTIFACTORYSTATE_ACTIVE, + Url: "https://example.com", + ConsumedQuotaBytes: 1024, + CreateTime: time.Now(), + Proxy: ®istry.Proxy{ + Url: "https://registry-1.docker.io", + Description: &desc, + RegistryType: "docker-hub", + }, + }, + }, + wantErr: false, + }, + { + name: "nil artifactory", + args: args{ + artifactory: nil, + }, + wantErr: true, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, "", tt.args.artifactory); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/registry/artifactory/list/list.go b/internal/cmd/registry/artifactory/list/list.go new file mode 100644 index 000000000..66f1a9a4c --- /dev/null +++ b/internal/cmd/registry/artifactory/list/list.go @@ -0,0 +1,144 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "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/registry/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Container Registry artifactories", + Long: "Lists all Container Registry artifactories.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all artifactories`, + "$ stackit registry artifactory list"), + examples.NewExample( + `List all artifactories in JSON format`, + "$ stackit registry artifactory list --output-format json"), + examples.NewExample( + `List up to 10 artifactories`, + "$ stackit registry artifactory list --limit 10"), + ), + 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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get artifactories: %w", err) + } + artifactories := resp.Artifactories + + // Truncate output + if model.Limit != nil && len(artifactories) > int(*model.Limit) { + artifactories = artifactories[:*model.Limit] + } + + 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 + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, artifactories) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + 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, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiListArtifactoriesRequest { + return apiClient.DefaultAPI.ListArtifactories(ctx, model.ProjectId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, artifactories []registry.Artifactory) error { + return p.OutputResult(outputFormat, artifactories, func() error { + if len(artifactories) == 0 { + p.Outputf("No artifactories found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "URL") + for i := range artifactories { + artifactory := artifactories[i] + table.AddRow( + artifactory.Id, + artifactory.Name, + string(artifactory.State), + artifactory.Url, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/registry/artifactory/list/list_test.go b/internal/cmd/registry/artifactory/list/list_test.go new file mode 100644 index 000000000..e0b73b7ed --- /dev/null +++ b/internal/cmd/registry/artifactory/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *registry.ApiListArtifactoriesRequest)) registry.ApiListArtifactoriesRequest { + request := testClient.DefaultAPI.ListArtifactories(testCtx, testProjectId, testRegion) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "10" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid: zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit invalid: negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest registry.ApiListArtifactoriesRequest + }{ + { + 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, registry.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + projectLabel string + artifactories []registry.Artifactory + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty artifactories", + args: args{ + projectLabel: "example-project", + artifactories: []registry.Artifactory{}, + }, + wantErr: false, + }, + { + name: "single item", + args: args{ + projectLabel: "example-project", + artifactories: []registry.Artifactory{ + { + Id: uuid.NewString(), + Name: "my-registry", + State: registry.ARTIFACTORYSTATE_ACTIVE, + Url: "https://example.com", + }, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, "", tt.args.projectLabel, tt.args.artifactories); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/registry/artifactory/update/update.go b/internal/cmd/registry/artifactory/update/update.go new file mode 100644 index 000000000..10cbdd8f5 --- /dev/null +++ b/internal/cmd/registry/artifactory/update/update.go @@ -0,0 +1,172 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/registry/client" + registryUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/registry/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + artifactoryIdArg = "ARTIFACTORY_ID" + + proxyUrlFlag = "proxy-url" + proxyDescriptionFlag = "proxy-description" + proxyAccessKeyFlag = "proxy-access-key" + proxyAccessSecretFlag = "proxy-access-secret" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ArtifactoryId string + ProxyUrl *string + ProxyDescription *string + ProxyAccessKey *string + ProxyAccessSecret *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", artifactoryIdArg), + Short: "Updates a Container Registry artifactory", + Long: "Updates a Container Registry artifactory.", + Args: args.SingleArg(artifactoryIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the proxy configuration for an artifactory with ID "xxx"`, + "$ stackit registry artifactory update xxx --proxy-url https://registry-1.docker.io"), + ), + 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 + } + + artifactoryLabel, err := registryUtils.GetArtifactoryName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ArtifactoryId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get artifactory name: %v", err) + artifactoryLabel = model.ArtifactoryId + } + + prompt := fmt.Sprintf("Are you sure you want to update artifactory %q?", artifactoryLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update artifactory: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Updating artifactory", func() error { + _, err = wait.UpdateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ArtifactoryId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for artifactory update: %w", err) + } + } + + return outputResult(params.Printer, model, artifactoryLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(proxyUrlFlag, "", "Upstream registry URL to proxy") + cmd.Flags().String(proxyDescriptionFlag, "", "Description of the proxy registry") + cmd.Flags().String(proxyAccessKeyFlag, "", "Access key for upstream proxy registry authentication") + cmd.Flags().String(proxyAccessSecretFlag, "", "Access secret for upstream proxy registry authentication") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + artifactoryId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + proxyUrl := flags.FlagToStringPointer(p, cmd, proxyUrlFlag) + proxyDesc := flags.FlagToStringPointer(p, cmd, proxyDescriptionFlag) + proxyKey := flags.FlagToStringPointer(p, cmd, proxyAccessKeyFlag) + proxySecret := flags.FlagToStringPointer(p, cmd, proxyAccessSecretFlag) + + if proxyUrl == nil && proxyDesc == nil && proxyKey == nil && proxySecret == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ArtifactoryId: artifactoryId, + ProxyUrl: proxyUrl, + ProxyDescription: proxyDesc, + ProxyAccessKey: proxyKey, + ProxyAccessSecret: proxySecret, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiPatchArtifactoryRequest { + req := apiClient.DefaultAPI.PatchArtifactory(ctx, model.ProjectId, model.Region, model.ArtifactoryId) + + proxyReq := ®istry.UpdateProxyRequest{ + Url: model.ProxyUrl, + Description: model.ProxyDescription, + } + + if model.ProxyAccessKey != nil && model.ProxyAccessSecret != nil { + proxyReq.Credentials = ®istry.Credentials{ + AccessKey: *model.ProxyAccessKey, + AccessSecret: *model.ProxyAccessSecret, + } + } + + return req.PatchArtifactoryPayload(registry.PatchArtifactoryPayload{ + Proxy: proxyReq, + }) +} + +func outputResult(p *print.Printer, model *inputModel, artifactoryLabel string, resp *registry.Artifactory) error { + if model == nil { + return fmt.Errorf("no model passed") + } + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s artifactory %q\n", operationState, artifactoryLabel) + return nil + }) +} diff --git a/internal/cmd/registry/artifactory/update/update_test.go b/internal/cmd/registry/artifactory/update/update_test.go new file mode 100644 index 000000000..55159f5fe --- /dev/null +++ b/internal/cmd/registry/artifactory/update/update_test.go @@ -0,0 +1,202 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() + testArtifactoryId = uuid.NewString() + testProxyUrl = "https://registry-1.docker.io" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + proxyUrlFlag: testProxyUrl, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ArtifactoryId: testArtifactoryId, + ProxyUrl: utils.Ptr(testProxyUrl), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *registry.ApiPatchArtifactoryRequest)) registry.ApiPatchArtifactoryRequest { + request := testClient.DefaultAPI.PatchArtifactory(testCtx, testProjectId, testRegion, testArtifactoryId). + PatchArtifactoryPayload(registry.PatchArtifactoryPayload{ + Proxy: ®istry.UpdateProxyRequest{ + Url: utils.Ptr(testProxyUrl), + }, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flags set", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, proxyUrlFlag) + }), + isValid: false, + }, + { + description: "project id missing", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + 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 registry.ApiPatchArtifactoryRequest + }{ + { + 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, registry.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + artifactoryLabel string + resp *registry.Artifactory + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "updated successfully sync", + args: args{ + model: fixtureInputModel(), + artifactoryLabel: "my-artifactory", + resp: ®istry.Artifactory{ + Id: testArtifactoryId, + }, + }, + wantErr: false, + }, + { + name: "updated successfully async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + artifactoryLabel: "my-artifactory", + resp: ®istry.Artifactory{ + Id: testArtifactoryId, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.artifactoryLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/registry/registry.go b/internal/cmd/registry/registry.go new file mode 100644 index 000000000..e9d230aa0 --- /dev/null +++ b/internal/cmd/registry/registry.go @@ -0,0 +1,26 @@ +package registry + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory" + "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: "registry", + Short: "Provides functionality for Container Registry", + Long: "Provides functionality for Container Registry.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(artifactory.NewCmd(params)) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 42c156ca2..7071b2b15 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -42,6 +42,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske" "github.com/stackitcloud/stackit-cli/internal/cmd/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/valkey" + "github.com/stackitcloud/stackit-cli/internal/cmd/registry" "github.com/stackitcloud/stackit-cli/internal/cmd/volume" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -210,6 +211,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(kms.NewCmd(params)) cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(valkey.NewCmd(params)) + cmd.AddCommand(registry.NewCmd(params)) } // traverseCommands calls f for c and all of its children. From 0797b550f0dfbe10978d58afd705ff3290066232 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:36:15 +0200 Subject: [PATCH 04/17] docs: generate documentation for registry commands --- docs/stackit.md | 1 + docs/stackit_registry.md | 34 ++++++++++++++ docs/stackit_registry_artifactory.md | 38 +++++++++++++++ docs/stackit_registry_artifactory_create.md | 41 ++++++++++++++++ docs/stackit_registry_artifactory_delete.md | 40 ++++++++++++++++ docs/stackit_registry_artifactory_describe.md | 43 +++++++++++++++++ docs/stackit_registry_artifactory_list.md | 47 +++++++++++++++++++ docs/stackit_registry_artifactory_update.md | 44 +++++++++++++++++ 8 files changed, 288 insertions(+) create mode 100644 docs/stackit_registry.md create mode 100644 docs/stackit_registry_artifactory.md create mode 100644 docs/stackit_registry_artifactory_create.md create mode 100644 docs/stackit_registry_artifactory_delete.md create mode 100644 docs/stackit_registry_artifactory_describe.md create mode 100644 docs/stackit_registry_artifactory_list.md create mode 100644 docs/stackit_registry_artifactory_update.md diff --git a/docs/stackit.md b/docs/stackit.md index 91fa48bed..8b26e0b41 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -54,6 +54,7 @@ stackit [flags] * [stackit public-ip](./stackit_public-ip.md) - Provides functionality for public IPs * [stackit quota](./stackit_quota.md) - Manage server quotas * [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ +* [stackit registry](./stackit_registry.md) - Provides functionality for Container Registry * [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager * [stackit security-group](./stackit_security-group.md) - Manage security groups * [stackit server](./stackit_server.md) - Provides functionality for servers diff --git a/docs/stackit_registry.md b/docs/stackit_registry.md new file mode 100644 index 000000000..b785e45b4 --- /dev/null +++ b/docs/stackit_registry.md @@ -0,0 +1,34 @@ +## stackit registry + +Provides functionality for Container Registry + +### Synopsis + +Provides functionality for Container Registry. + +``` +stackit registry [flags] +``` + +### Options + +``` + -h, --help Help for "stackit registry" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_registry_artifactory.md b/docs/stackit_registry_artifactory.md new file mode 100644 index 000000000..8cc16e675 --- /dev/null +++ b/docs/stackit_registry_artifactory.md @@ -0,0 +1,38 @@ +## stackit registry artifactory + +Provides functionality for Container Registry artifactories + +### Synopsis + +Provides functionality for Container Registry artifactories. + +``` +stackit registry artifactory [flags] +``` + +### Options + +``` + -h, --help Help for "stackit registry artifactory" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit registry](./stackit_registry.md) - Provides functionality for Container Registry +* [stackit registry artifactory create](./stackit_registry_artifactory_create.md) - Creates a Container Registry artifactory +* [stackit registry artifactory delete](./stackit_registry_artifactory_delete.md) - Deletes a Container Registry artifactory +* [stackit registry artifactory describe](./stackit_registry_artifactory_describe.md) - Shows details of a Container Registry artifactory +* [stackit registry artifactory list](./stackit_registry_artifactory_list.md) - Lists all Container Registry artifactories +* [stackit registry artifactory update](./stackit_registry_artifactory_update.md) - Updates a Container Registry artifactory + diff --git a/docs/stackit_registry_artifactory_create.md b/docs/stackit_registry_artifactory_create.md new file mode 100644 index 000000000..4c4fe060e --- /dev/null +++ b/docs/stackit_registry_artifactory_create.md @@ -0,0 +1,41 @@ +## stackit registry artifactory create + +Creates a Container Registry artifactory + +### Synopsis + +Creates a Container Registry artifactory. + +``` +stackit registry artifactory create [flags] +``` + +### Examples + +``` + Create an artifactory with name "my-registry" + $ stackit registry artifactory create --name my-registry +``` + +### Options + +``` + -h, --help Help for "stackit registry artifactory create" + -n, --name string Artifactory name +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_registry_artifactory_delete.md b/docs/stackit_registry_artifactory_delete.md new file mode 100644 index 000000000..4125f4f35 --- /dev/null +++ b/docs/stackit_registry_artifactory_delete.md @@ -0,0 +1,40 @@ +## stackit registry artifactory delete + +Deletes a Container Registry artifactory + +### Synopsis + +Deletes a Container Registry artifactory. + +``` +stackit registry artifactory delete ARTIFACTORY_ID [flags] +``` + +### Examples + +``` + Delete an artifactory with ID "xxx" + $ stackit registry artifactory delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit registry artifactory delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_registry_artifactory_describe.md b/docs/stackit_registry_artifactory_describe.md new file mode 100644 index 000000000..f487cd010 --- /dev/null +++ b/docs/stackit_registry_artifactory_describe.md @@ -0,0 +1,43 @@ +## stackit registry artifactory describe + +Shows details of a Container Registry artifactory + +### Synopsis + +Shows details of a Container Registry artifactory. + +``` +stackit registry artifactory describe ARTIFACTORY_ID [flags] +``` + +### Examples + +``` + Get details of an artifactory with ID "xxx" + $ stackit registry artifactory describe xxx + + Get details of an artifactory with ID "xxx" in JSON format + $ stackit registry artifactory describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit registry artifactory describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_registry_artifactory_list.md b/docs/stackit_registry_artifactory_list.md new file mode 100644 index 000000000..cdea6513b --- /dev/null +++ b/docs/stackit_registry_artifactory_list.md @@ -0,0 +1,47 @@ +## stackit registry artifactory list + +Lists all Container Registry artifactories + +### Synopsis + +Lists all Container Registry artifactories. + +``` +stackit registry artifactory list [flags] +``` + +### Examples + +``` + List all artifactories + $ stackit registry artifactory list + + List all artifactories in JSON format + $ stackit registry artifactory list --output-format json + + List up to 10 artifactories + $ stackit registry artifactory list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit registry artifactory list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_registry_artifactory_update.md b/docs/stackit_registry_artifactory_update.md new file mode 100644 index 000000000..9846c2756 --- /dev/null +++ b/docs/stackit_registry_artifactory_update.md @@ -0,0 +1,44 @@ +## stackit registry artifactory update + +Updates a Container Registry artifactory + +### Synopsis + +Updates a Container Registry artifactory. + +``` +stackit registry artifactory update ARTIFACTORY_ID [flags] +``` + +### Examples + +``` + Update the proxy configuration for an artifactory with ID "xxx" + $ stackit registry artifactory update xxx --proxy-url https://registry-1.docker.io +``` + +### Options + +``` + -h, --help Help for "stackit registry artifactory update" + --proxy-access-key string Access key for upstream proxy registry authentication + --proxy-access-secret string Access secret for upstream proxy registry authentication + --proxy-description string Description of the proxy registry + --proxy-url string Upstream registry URL to proxy +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories + From d8b305ab81a0d2f3d54a7c2008613f69a881b03f Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:42:36 +0200 Subject: [PATCH 05/17] fix(security): bump go to 1.26.10 to mitigate CVE-2026-111943 Update Go toolchain and runtime requirement to 1.26.10 addressing intermediate CA validation compromise (go.dev/issue/82345). --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 01aaa0487..bd5ccdebf 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stackitcloud/stackit-cli -go 1.26.0 +go 1.26.10 require ( github.com/fatih/color v1.19.0 @@ -30,6 +30,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.13.0 github.com/stackitcloud/stackit-sdk-go/services/opensearch v1.5.0 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.15.0 + github.com/stackitcloud/stackit-sdk-go/services/registry v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.26.1 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.19.1 @@ -208,7 +209,6 @@ require ( github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stackitcloud/stackit-sdk-go/services/registry v0.1.0 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.11.1 // indirect From 0d383363fa7bca8dfc8dc9679c1b27890a1271fe Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:51:17 +0200 Subject: [PATCH 06/17] style: sort registry import in root command --- internal/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7071b2b15..d28f7d80d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -34,6 +34,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/quota" "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq" "github.com/stackitcloud/stackit-cli/internal/cmd/redis" + "github.com/stackitcloud/stackit-cli/internal/cmd/registry" secretsmanager "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager" securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/security-group" "github.com/stackitcloud/stackit-cli/internal/cmd/server" @@ -42,7 +43,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske" "github.com/stackitcloud/stackit-cli/internal/cmd/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/valkey" - "github.com/stackitcloud/stackit-cli/internal/cmd/registry" "github.com/stackitcloud/stackit-cli/internal/cmd/volume" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" From fba0f07c99f42a2a8f59fabc16b66c279327c72a Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 13:01:33 +0200 Subject: [PATCH 07/17] feat(registry): support proxy configuration on artifactory create --- .../cmd/registry/artifactory/create/create.go | 72 ++++++++++++-- .../artifactory/create/create_test.go | 97 ++++++++++++++++++- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/internal/cmd/registry/artifactory/create/create.go b/internal/cmd/registry/artifactory/create/create.go index c24665b07..62e5d0022 100644 --- a/internal/cmd/registry/artifactory/create/create.go +++ b/internal/cmd/registry/artifactory/create/create.go @@ -22,11 +22,22 @@ import ( const ( nameFlag = "name" + + proxyUrlFlag = "proxy-url" + proxyRegistryTypeFlag = "proxy-registry-type" + proxyDescriptionFlag = "proxy-description" + proxyAccessKeyFlag = "proxy-access-key" + proxyAccessSecretFlag = "proxy-access-secret" ) type inputModel struct { *globalflags.GlobalFlagModel - Name string + Name string + ProxyUrl *string + ProxyRegistryType *string + ProxyDescription *string + ProxyAccessKey *string + ProxyAccessSecret *string } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -38,7 +49,16 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Create an artifactory with name "my-registry"`, - "$ stackit registry artifactory create --name my-registry"), + "$ stackit registry artifactory create --name my-registry", + ), + examples.NewExample( + `Create a proxy-cache artifactory for Docker Hub`, + "$ stackit registry artifactory create --name docker-proxy --proxy-url https://registry-1.docker.io --proxy-registry-type docker-hub", + ), + examples.NewExample( + `Create a proxy-cache artifactory with credentials`, + "$ stackit registry artifactory create --name my-proxy --proxy-url https://custom-registry.com --proxy-registry-type harbor --proxy-access-key my-user --proxy-access-secret my-secret", + ), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -93,9 +113,17 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(nameFlag, "n", "", "Artifactory name") + cmd.Flags().String(proxyUrlFlag, "", "Upstream registry URL to proxy") + cmd.Flags().String(proxyRegistryTypeFlag, "", "Registry type of the upstream proxy (e.g., docker-hub, harbor)") + cmd.Flags().String(proxyDescriptionFlag, "", "Description of the proxy registry") + cmd.Flags().String(proxyAccessKeyFlag, "", "Access key for upstream proxy registry authentication") + cmd.Flags().String(proxyAccessSecretFlag, "", "Access secret for upstream proxy registry authentication") err := flags.MarkFlagsRequired(cmd, nameFlag) cobra.CheckErr(err) + + cmd.MarkFlagsRequiredTogether(proxyUrlFlag, proxyRegistryTypeFlag) + cmd.MarkFlagsRequiredTogether(proxyAccessKeyFlag, proxyAccessSecretFlag) } func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { @@ -104,9 +132,25 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, return nil, &cliErr.ProjectIdError{} } + proxyUrl := flags.FlagToStringPointer(p, cmd, proxyUrlFlag) + proxyRegistryType := flags.FlagToStringPointer(p, cmd, proxyRegistryTypeFlag) + proxyDesc := flags.FlagToStringPointer(p, cmd, proxyDescriptionFlag) + proxyKey := flags.FlagToStringPointer(p, cmd, proxyAccessKeyFlag) + proxySecret := flags.FlagToStringPointer(p, cmd, proxyAccessSecretFlag) + + hasProxyDetails := proxyDesc != nil || proxyKey != nil || proxySecret != nil + if hasProxyDetails && (proxyUrl == nil || proxyRegistryType == nil) { + return nil, fmt.Errorf("both --%s and --%s are required when configuring a proxy", proxyUrlFlag, proxyRegistryTypeFlag) + } + model := inputModel{ - GlobalFlagModel: globalFlags, - Name: flags.FlagToStringValue(p, cmd, nameFlag), + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + ProxyUrl: proxyUrl, + ProxyRegistryType: proxyRegistryType, + ProxyDescription: proxyDesc, + ProxyAccessKey: proxyKey, + ProxyAccessSecret: proxySecret, } p.DebugInputModel(model) @@ -115,9 +159,25 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiCreateArtifactoryRequest { req := apiClient.DefaultAPI.CreateArtifactory(ctx, model.ProjectId, model.Region) - return req.CreateArtifactoryPayload(registry.CreateArtifactoryPayload{ + payload := registry.CreateArtifactoryPayload{ Name: model.Name, - }) + } + + if model.ProxyUrl != nil && model.ProxyRegistryType != nil { + proxyReq := registry.NewCreateProxyRequest(*model.ProxyRegistryType, *model.ProxyUrl) + if model.ProxyDescription != nil { + proxyReq.Description = model.ProxyDescription + } + if model.ProxyAccessKey != nil && model.ProxyAccessSecret != nil { + proxyReq.Credentials = ®istry.Credentials{ + AccessKey: *model.ProxyAccessKey, + AccessSecret: *model.ProxyAccessSecret, + } + } + payload.Proxy = proxyReq + } + + return req.CreateArtifactoryPayload(payload) } func outputResult(p *print.Printer, model *inputModel, projectLabel, artifactoryId string, resp *registry.Artifactory) error { diff --git a/internal/cmd/registry/artifactory/create/create_test.go b/internal/cmd/registry/artifactory/create/create_test.go index 9812f1e51..fef8e7606 100644 --- a/internal/cmd/registry/artifactory/create/create_test.go +++ b/internal/cmd/registry/artifactory/create/create_test.go @@ -12,6 +12,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) const ( @@ -22,10 +23,15 @@ const ( type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} - testProjectId = uuid.NewString() - testName = "my-registry" + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() + testName = "my-registry" + testProxyUrl = "https://registry-1.docker.io" + testProxyRegistryType = "docker-hub" + testProxyDescription = "Docker Hub proxy cache" + testProxyAccessKey = "my-user" + testProxyAccessSecret = "my-secret" ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -79,6 +85,66 @@ func TestParseInput(t *testing.T) { isValid: true, expectedModel: fixtureInputModel(), }, + { + description: "with proxy minimal", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = utils.Ptr(testProxyUrl) + model.ProxyRegistryType = utils.Ptr(testProxyRegistryType) + }), + }, + { + description: "with proxy full", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + flagValues[proxyDescriptionFlag] = testProxyDescription + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + flagValues[proxyAccessSecretFlag] = testProxyAccessSecret + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = utils.Ptr(testProxyUrl) + model.ProxyRegistryType = utils.Ptr(testProxyRegistryType) + model.ProxyDescription = utils.Ptr(testProxyDescription) + model.ProxyAccessKey = utils.Ptr(testProxyAccessKey) + model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) + }), + }, + { + description: "proxy url without registry type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + }), + isValid: false, + }, + { + description: "proxy registry type without url", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + }), + isValid: false, + }, + { + description: "proxy access key without secret", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + }), + isValid: false, + }, + { + description: "proxy description without proxy url", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyDescriptionFlag] = testProxyDescription + }), + isValid: false, + }, { description: "name missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { @@ -127,6 +193,29 @@ func TestBuildRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "with proxy full", + model: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = utils.Ptr(testProxyUrl) + model.ProxyRegistryType = utils.Ptr(testProxyRegistryType) + model.ProxyDescription = utils.Ptr(testProxyDescription) + model.ProxyAccessKey = utils.Ptr(testProxyAccessKey) + model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) + }), + expectedRequest: testClient.DefaultAPI.CreateArtifactory(testCtx, testProjectId, testRegion). + CreateArtifactoryPayload(registry.CreateArtifactoryPayload{ + Name: testName, + Proxy: ®istry.CreateProxyRequest{ + Url: testProxyUrl, + RegistryType: testProxyRegistryType, + Description: utils.Ptr(testProxyDescription), + Credentials: ®istry.Credentials{ + AccessKey: testProxyAccessKey, + AccessSecret: testProxyAccessSecret, + }, + }, + }), + }, } for _, tt := range tests { From 3b7b49051f0f69332616940eae0900df5653a99d Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 13:01:33 +0200 Subject: [PATCH 08/17] docs: update artifactory create documentation with proxy flags --- docs/stackit_registry_artifactory_create.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/stackit_registry_artifactory_create.md b/docs/stackit_registry_artifactory_create.md index 4c4fe060e..6cab764cc 100644 --- a/docs/stackit_registry_artifactory_create.md +++ b/docs/stackit_registry_artifactory_create.md @@ -15,13 +15,24 @@ stackit registry artifactory create [flags] ``` Create an artifactory with name "my-registry" $ stackit registry artifactory create --name my-registry + + Create a proxy-cache artifactory for Docker Hub + $ stackit registry artifactory create --name docker-proxy --proxy-url https://registry-1.docker.io --proxy-registry-type docker-hub + + Create a proxy-cache artifactory with credentials + $ stackit registry artifactory create --name my-proxy --proxy-url https://custom-registry.com --proxy-registry-type harbor --proxy-access-key my-user --proxy-access-secret my-secret ``` ### Options ``` - -h, --help Help for "stackit registry artifactory create" - -n, --name string Artifactory name + -h, --help Help for "stackit registry artifactory create" + -n, --name string Artifactory name + --proxy-access-key string Access key for upstream proxy registry authentication + --proxy-access-secret string Access secret for upstream proxy registry authentication + --proxy-description string Description of the proxy registry + --proxy-registry-type string Registry type of the upstream proxy (e.g., docker-hub, harbor) + --proxy-url string Upstream registry URL to proxy ``` ### Options inherited from parent commands From 92766842180b538058a9099ac75e5d18f5fb5fa6 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Thu, 22 Oct 2026 13:51:46 +0200 Subject: [PATCH 09/17] chroe(cli): revert bump --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bd5ccdebf..205009526 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stackitcloud/stackit-cli -go 1.26.10 +go 1.26.0 require ( github.com/fatih/color v1.19.0 From ba6e7d5c8d3ff1819c78650005c726533e32fff2 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:44:40 +0200 Subject: [PATCH 10/17] docs(review): add code review report for STACKITCRE-382 --- .report/STACKITCRE-382.md | 162 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .report/STACKITCRE-382.md diff --git a/.report/STACKITCRE-382.md b/.report/STACKITCRE-382.md new file mode 100644 index 000000000..5992eb82e --- /dev/null +++ b/.report/STACKITCRE-382.md @@ -0,0 +1,162 @@ +# Code Review Report - STACKITCRE-382 + +**Rama analizada:** `feat/STACKITCRE-382-registry` (comparada contra `origin/main`) +**Fecha:** 2026-10-23 +**Revisor:** Senior Code Reviewer + +--- + +## Resumen Ejecutivo + +Se revisó la implementación de los nuevos comandos para Container Registry (`stackit registry artifactory [create|delete|describe|list|update]`), la configuración del cliente API y la documentación generada. + +La estructura general y la integración con Cobra y el SDK de STACKIT son sólidas y siguen los patrones base de `stackit-cli`. Todos los hallazgos de código (ítems 1 al 8) han sido corregidos y validados con tests unitarios y linter. El ítem 9 (enmienda de commit) queda reservado para la etapa de commit/rebase interactivo cuando el usuario lo autorice. + +--- + +## Estado de Resolución + +- [x] **Ítem 1 (Major):** Validación conjunta obligatoria de credenciales proxy (`MarkFlagsRequiredTogether`) en `update.go`. +- [x] **Ítem 2 (Major):** Manejo seguro de secretos con `flags.SecretFlag`, `SecretFlagToStringPointer` y `json:"-"` en `create.go` y `update.go`. +- [x] **Ítem 3 (Major):** Paginación completa con seguimiento de `NextPageToken` y respeto de `--limit` en `list.go`. +- [x] **Ítem 4 (Minor):** Soporte para `--registry-custom-endpoint` en `config set`, `config unset` y `profile.json`. +- [x] **Ítem 5 (Minor):** Verificación de respuesta nil en `GetArtifactoryName` y suite de tests unitarios en `utils_test.go`. +- [x] **Ítem 6 (Minor):** Captura de entidad actualizada retornada por los `WaitHandler` en `create.go` y `update.go`. +- [x] **Ítem 7 (Suggestion):** Formato homogéneo de fecha `time.DateTime` en la tabla de `describe.go`. +- [x] **Ítem 8 (Suggestion):** Ampliación de la cobertura de pruebas unitarias en `update_test.go`. +- [ ] **Ítem 9 (Suggestion):** Corrección tipográfica en mensaje de commit `92766842` (`chroe` -> `chore`) pendiente de rebase/amend cuando el usuario lo indique. + +--- + +## Hallazgos + +### 1. Descarte silencioso de credenciales por omisión de `MarkFlagsRequiredTogether` +- **Severidad:** `Major` +- **Ubicación:** [update.go:103-108](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L103-L108), [update.go:148-153](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L148-L153) +- **Problema concreto:** En `update.go`, no se vinculó `cmd.MarkFlagsRequiredTogether(proxyAccessKeyFlag, proxyAccessSecretFlag)` como sí se hizo en `create.go`. Si un usuario ejecuta el comando especificando únicamente `--proxy-access-key` (o `--proxy-access-secret`), la validación pasa con éxito. No obstante, en `buildRequest` la condición `model.ProxyAccessKey != nil && model.ProxyAccessSecret != nil` se evalúa como falsa, por lo que `proxyReq.Credentials` queda `nil`. La petición se envía a la API sin credenciales y el CLI emite `"Updated artifactory..."`, descartando silenciosamente la credencial provista por el usuario sin ningún aviso o error. +- **Sugerencia de corrección:** + Configurar la regla de flags en `configureFlags`: + ```go + func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(proxyUrlFlag, "", "Upstream registry URL to proxy") + cmd.Flags().String(proxyDescriptionFlag, "", "Description of the proxy registry") + cmd.Flags().String(proxyAccessKeyFlag, "", "Access key for upstream proxy registry authentication") + cmd.Flags().String(proxyAccessSecretFlag, "", "Access secret for upstream proxy registry authentication") + + cmd.MarkFlagsRequiredTogether(proxyAccessKeyFlag, proxyAccessSecretFlag) + } + ``` + +--- + +### 2. Exposición de secretos en línea de comandos, logs de depuración y falta de soporte para `@file`/`stdin` +- **Severidad:** `Major` +- **Ubicación:** [create.go:120](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/create/create.go#L120), [update.go:107](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L107), [create.go:33-41](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/create/create.go#L33-L41) +- **Problema concreto:** El flag `--proxy-access-secret` se registró como un flag string plano ordinario (`cmd.Flags().String`). En todo el CLI (`alb`, `cdn`, `load-balancer`, `intake`), los secretos deben configurarse mediante `flags.SecretFlag(proxyAccessSecretFlag, params)` y extraerse con `flags.SecretFlagToStringPointer`. La implementación actual causa: + 1. Exposición del secreto en el historial de shell y en la lista de procesos (`ps aux`). + 2. Imposibilidad de suministrar el secreto de forma segura mediante un archivo (`--proxy-access-secret @./secret.txt`) o stdin. + 3. Fuga del secreto en texto plano en los logs de depuración: `p.DebugInputModel(model)` convierte `inputModel` a JSON y luego a mapa; al ser un campo exportado sin ofuscación, el secreto se imprime en los logs de debug. +- **Sugerencia de corrección:** + Usar `flags.SecretFlag` y proteger el modelo: + ```go + // En configureFlags(cmd *cobra.Command, params *types.CmdParams): + secretFlag := flags.SecretFlag(proxyAccessSecretFlag, params) + cmd.Flags().Var(secretFlag, proxyAccessSecretFlag, secretFlag.Usage()) + + // En parseInput: + ProxyAccessSecret: flags.SecretFlagToStringPointer(p, cmd, proxyAccessSecretFlag), + + // En inputModel: + ProxyAccessSecret *string `json:"-"` + ``` + +--- + +### 3. Falta de paginación en `list` (pérdida de resultados) +- **Severidad:** `Major` +- **Ubicación:** [list.go:62-73](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/list/list.go#L62-L73) +- **Problema concreto:** `ApiListArtifactoriesRequest` admite `NextPageToken` y `PageSize`, y la API responde con un `NextPageToken`. Actualmente, `list.go` solo ejecuta un único llamado: + ```go + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + ``` + Si el proyecto cuenta con más elementos que el tamaño de página por defecto o si el usuario indica un `--limit` superior a una sola página, los elementos posteriores nunca se obtienen ni se muestran, reportando una lista incompleta de artifactories. +- **Sugerencia de corrección:** + Implementar el bucle de paginación siguiendo `NextPageToken` hasta alcanzar el `limit` o hasta que no haya más páginas, siguiendo el patrón de servicios como `beta/cdn/distribution/list/list.go`. + +--- + +### 4. Inconsistencia de configuración: endpoint de registry no expuesto en `config set` / `config unset` +- **Severidad:** `Minor` +- **Ubicación:** [config.go:62](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/pkg/config/config.go#L62), [set.go:58](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/config/set/set.go#L58), [unset.go:61](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/config/unset/unset.go#L61) +- **Problema concreto:** Se añadió `RegistryCustomEndpointKey` a `internal/pkg/config/config.go` y se consume en `internal/pkg/services/registry/client/client.go`. Sin embargo, no se crearon los flags correspondientes `--registry-custom-endpoint` ni en `stackit config set` ni en `stackit config unset`, ni se agregó al template de perfiles `profile.json`. En consecuencia, ningún usuario puede configurar ni eliminar dicho endpoint mediante los comandos estándar de configuración. +- **Sugerencia de corrección:** + Agregar el flag `registryCustomEndpointFlag = "registry-custom-endpoint"` y su enlace con viper en `internal/cmd/config/set/set.go` e `internal/cmd/config/unset/unset.go`. + +--- + +### 5. Falta de verificación de nil en `GetArtifactoryName` y ausencia total de `utils_test.go` +- **Severidad:** `Minor` +- **Ubicación:** [utils.go:10-15](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/pkg/services/registry/utils/utils.go#L10-L15) +- **Problema concreto:** La función `GetArtifactoryName` desreferencia directamente `resp.Name` tras el llamado API sin verificar previamente si `resp != nil`. Además, el paquete `internal/pkg/services/registry/utils` no cuenta con archivo de tests unitarios (`utils_test.go`), a diferencia de prácticamente todos los demás servicios bajo `internal/pkg/services/*/utils/`. +- **Sugerencia de corrección:** + Agregar validación de nil y crear `utils_test.go` con mocks del servicio: + ```go + func GetArtifactoryName(ctx context.Context, apiClient registry.DefaultAPI, projectId, region, artifactoryId string) (string, error) { + resp, err := apiClient.GetArtifactory(ctx, projectId, region, artifactoryId).Execute() + if err != nil { + return "", fmt.Errorf("get artifactory: %w", err) + } + if resp == nil { + return "", fmt.Errorf("get artifactory: empty response") + } + return resp.Name, nil + } + ``` + +--- + +### 6. Descarte de la respuesta actualizada del `WaitHandler` en modo sincrónico +- **Severidad:** `Minor` +- **Ubicación:** [create.go:99](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/create/create.go#L99), [update.go:88](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L88) +- **Problema concreto:** Al completarse la espera (`wait.CreateArtifactoryWaitHandler` o `wait.UpdateArtifactoryWaitHandler`), el handler retorna la entidad `*registry.Artifactory` con su estado actualizado. Sin embargo, el código ignora dicho retorno (`_, err = ...`) y le pasa a `outputResult` el `resp` inicial devuelto por la llamada de creación/actualización. En formatos de salida como JSON (`-o json`), la respuesta puede reflejar estados intermedios en vez del estado consolidado tras la espera. +- **Sugerencia de corrección:** + Capturar la respuesta del wait handler: + ```go + err := spinner.Run(params.Printer, "Creating artifactory", func() error { + resp, err = wait.CreateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, artifactoryId).WaitWithContext(ctx) + return err + }) + ``` + +--- + +### 7. Formato crudo de fecha en salida de tabla de `describe` +- **Severidad:** `Suggestion` +- **Ubicación:** [describe.go:108](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/describe/describe.go#L108) +- **Problema concreto:** `table.AddRow("CREATED AT", artifactory.CreateTime.String())` invoca el método por defecto de Go `time.Time.String()`, imprimiendo timestamps con sufijos de zona y reloj monotónico desalineados con el resto de la CLI. En toda la aplicación se utiliza `artifactory.CreateTime.Format(time.DateTime)` o `time.RFC3339`. +- **Sugerencia de corrección:** + ```go + table.AddRow("CREATED AT", artifactory.CreateTime.Format(time.DateTime)) + ``` + +--- + +### 8. Cobertura incompleta de casos de prueba en `update_test.go` +- **Severidad:** `Suggestion` +- **Ubicación:** [update_test.go:74-140](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update_test.go#L74-L140) +- **Problema concreto:** La suite de pruebas de `update` solo cubre el escenario en el que se modifica `--proxy-url`. No existen casos de prueba para verificar: + - Actualización de `--proxy-description`. + - Actualización con credenciales (`--proxy-access-key` y `--proxy-access-secret`). + - Validación de combinaciones inválidas (por ejemplo, clave sin secreto). +- **Sugerencia de corrección:** + Añadir casos de prueba para cada bandera de proxy en `TestParseInput` y `TestBuildRequest`. + +--- + +### 9. Typo en mensaje de commit (Conventional Commits) +- **Severidad:** `Suggestion` +- **Ubicación:** Commit `92766842` +- **Problema concreto:** El commit `92766842` contiene un error tipográfico en el tipo de cambio: `chroe(cli): revert bump` en lugar de `chore(cli): revert bump`. +- **Sugerencia de corrección:** + Corregir mediante rebase interactivo (`git commit --amend`) antes de integrar a la rama principal. From e7187a7e0aed832d75a66c120efa02fcd9eca5fb Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:44:58 +0200 Subject: [PATCH 11/17] fix(registry): handle secret flag safely and require credentials pair in update --- docs/stackit_registry_artifactory_create.md | 2 +- docs/stackit_registry_artifactory_update.md | 2 +- .../cmd/registry/artifactory/create/create.go | 20 +++++++++++------ .../cmd/registry/artifactory/update/update.go | 22 +++++++++++++------ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/docs/stackit_registry_artifactory_create.md b/docs/stackit_registry_artifactory_create.md index 6cab764cc..2707c3efc 100644 --- a/docs/stackit_registry_artifactory_create.md +++ b/docs/stackit_registry_artifactory_create.md @@ -29,7 +29,7 @@ stackit registry artifactory create [flags] -h, --help Help for "stackit registry artifactory create" -n, --name string Artifactory name --proxy-access-key string Access key for upstream proxy registry authentication - --proxy-access-secret string Access secret for upstream proxy registry authentication + --proxy-access-secret string Proxy-Access-Secret. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty. --proxy-description string Description of the proxy registry --proxy-registry-type string Registry type of the upstream proxy (e.g., docker-hub, harbor) --proxy-url string Upstream registry URL to proxy diff --git a/docs/stackit_registry_artifactory_update.md b/docs/stackit_registry_artifactory_update.md index 9846c2756..71a370967 100644 --- a/docs/stackit_registry_artifactory_update.md +++ b/docs/stackit_registry_artifactory_update.md @@ -22,7 +22,7 @@ stackit registry artifactory update ARTIFACTORY_ID [flags] ``` -h, --help Help for "stackit registry artifactory update" --proxy-access-key string Access key for upstream proxy registry authentication - --proxy-access-secret string Access secret for upstream proxy registry authentication + --proxy-access-secret string Proxy-Access-Secret. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty. --proxy-description string Description of the proxy registry --proxy-url string Upstream registry URL to proxy ``` diff --git a/internal/cmd/registry/artifactory/create/create.go b/internal/cmd/registry/artifactory/create/create.go index 62e5d0022..776a30690 100644 --- a/internal/cmd/registry/artifactory/create/create.go +++ b/internal/cmd/registry/artifactory/create/create.go @@ -37,7 +37,7 @@ type inputModel struct { ProxyRegistryType *string ProxyDescription *string ProxyAccessKey *string - ProxyAccessSecret *string + ProxyAccessSecret *string `json:"-"` } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -96,8 +96,12 @@ func NewCmd(params *types.CmdParams) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { err := spinner.Run(params.Printer, "Creating artifactory", func() error { - _, err = wait.CreateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, artifactoryId).WaitWithContext(ctx) - return err + waitResp, waitErr := wait.CreateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, artifactoryId).WaitWithContext(ctx) + if waitErr != nil { + return waitErr + } + resp = waitResp + return nil }) if err != nil { return fmt.Errorf("wait for artifactory creation: %w", err) @@ -107,17 +111,19 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return outputResult(params.Printer, model, projectLabel, artifactoryId, resp) }, } - configureFlags(cmd) + configureFlags(cmd, params) return cmd } -func configureFlags(cmd *cobra.Command) { +func configureFlags(cmd *cobra.Command, params *types.CmdParams) { cmd.Flags().StringP(nameFlag, "n", "", "Artifactory name") cmd.Flags().String(proxyUrlFlag, "", "Upstream registry URL to proxy") cmd.Flags().String(proxyRegistryTypeFlag, "", "Registry type of the upstream proxy (e.g., docker-hub, harbor)") cmd.Flags().String(proxyDescriptionFlag, "", "Description of the proxy registry") cmd.Flags().String(proxyAccessKeyFlag, "", "Access key for upstream proxy registry authentication") - cmd.Flags().String(proxyAccessSecretFlag, "", "Access secret for upstream proxy registry authentication") + + secret := flags.SecretFlag(proxyAccessSecretFlag, params) + cmd.Flags().Var(secret, proxyAccessSecretFlag, secret.Usage()) err := flags.MarkFlagsRequired(cmd, nameFlag) cobra.CheckErr(err) @@ -136,7 +142,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, proxyRegistryType := flags.FlagToStringPointer(p, cmd, proxyRegistryTypeFlag) proxyDesc := flags.FlagToStringPointer(p, cmd, proxyDescriptionFlag) proxyKey := flags.FlagToStringPointer(p, cmd, proxyAccessKeyFlag) - proxySecret := flags.FlagToStringPointer(p, cmd, proxyAccessSecretFlag) + proxySecret := flags.SecretFlagToStringPointer(p, cmd, proxyAccessSecretFlag) hasProxyDetails := proxyDesc != nil || proxyKey != nil || proxySecret != nil if hasProxyDetails && (proxyUrl == nil || proxyRegistryType == nil) { diff --git a/internal/cmd/registry/artifactory/update/update.go b/internal/cmd/registry/artifactory/update/update.go index 10cbdd8f5..8db95bd2e 100644 --- a/internal/cmd/registry/artifactory/update/update.go +++ b/internal/cmd/registry/artifactory/update/update.go @@ -36,7 +36,7 @@ type inputModel struct { ProxyUrl *string ProxyDescription *string ProxyAccessKey *string - ProxyAccessSecret *string + ProxyAccessSecret *string `json:"-"` } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -85,8 +85,12 @@ func NewCmd(params *types.CmdParams) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { err := spinner.Run(params.Printer, "Updating artifactory", func() error { - _, err = wait.UpdateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ArtifactoryId).WaitWithContext(ctx) - return err + waitResp, waitErr := wait.UpdateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ArtifactoryId).WaitWithContext(ctx) + if waitErr != nil { + return waitErr + } + resp = waitResp + return nil }) if err != nil { return fmt.Errorf("wait for artifactory update: %w", err) @@ -96,15 +100,19 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return outputResult(params.Printer, model, artifactoryLabel, resp) }, } - configureFlags(cmd) + configureFlags(cmd, params) return cmd } -func configureFlags(cmd *cobra.Command) { +func configureFlags(cmd *cobra.Command, params *types.CmdParams) { cmd.Flags().String(proxyUrlFlag, "", "Upstream registry URL to proxy") cmd.Flags().String(proxyDescriptionFlag, "", "Description of the proxy registry") cmd.Flags().String(proxyAccessKeyFlag, "", "Access key for upstream proxy registry authentication") - cmd.Flags().String(proxyAccessSecretFlag, "", "Access secret for upstream proxy registry authentication") + + secret := flags.SecretFlag(proxyAccessSecretFlag, params) + cmd.Flags().Var(secret, proxyAccessSecretFlag, secret.Usage()) + + cmd.MarkFlagsRequiredTogether(proxyAccessKeyFlag, proxyAccessSecretFlag) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -118,7 +126,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu proxyUrl := flags.FlagToStringPointer(p, cmd, proxyUrlFlag) proxyDesc := flags.FlagToStringPointer(p, cmd, proxyDescriptionFlag) proxyKey := flags.FlagToStringPointer(p, cmd, proxyAccessKeyFlag) - proxySecret := flags.FlagToStringPointer(p, cmd, proxyAccessSecretFlag) + proxySecret := flags.SecretFlagToStringPointer(p, cmd, proxyAccessSecretFlag) if proxyUrl == nil && proxyDesc == nil && proxyKey == nil && proxySecret == nil { return nil, &cliErr.EmptyUpdateError{} From 8f33dc1cafc38347672fd0b728d7cf551f5aec73 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:45:24 +0200 Subject: [PATCH 12/17] fix(registry): add pagination support to artifactory list command --- .../cmd/registry/artifactory/list/list.go | 62 +++++++++++++++---- .../registry/artifactory/list/list_test.go | 16 ++++- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/internal/cmd/registry/artifactory/list/list.go b/internal/cmd/registry/artifactory/list/list.go index 66f1a9a4c..87829e49b 100644 --- a/internal/cmd/registry/artifactory/list/list.go +++ b/internal/cmd/registry/artifactory/list/list.go @@ -3,6 +3,7 @@ package list import ( "context" "fmt" + "math" "github.com/spf13/cobra" registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" @@ -20,7 +21,8 @@ import ( ) const ( - limitFlag = "limit" + limitFlag = "limit" + maxPageSize = 100 ) type inputModel struct { @@ -59,16 +61,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - req := buildRequest(ctx, model, apiClient) - resp, err := req.Execute() + artifactories, err := fetchArtifactories(ctx, model, apiClient) if err != nil { - return fmt.Errorf("get artifactories: %w", err) - } - artifactories := resp.Artifactories - - // Truncate output - if model.Limit != nil && len(artifactories) > int(*model.Limit) { - artifactories = artifactories[:*model.Limit] + return err } projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) @@ -112,8 +107,51 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, return &model, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient) registry.ApiListArtifactoriesRequest { - return apiClient.DefaultAPI.ListArtifactories(ctx, model.ProjectId, model.Region) +func buildRequest(ctx context.Context, model *inputModel, apiClient *registry.APIClient, pageToken string, pageSize int32) registry.ApiListArtifactoriesRequest { + req := apiClient.DefaultAPI.ListArtifactories(ctx, model.ProjectId, model.Region) + if pageToken != "" { + req = req.NextPageToken(pageToken) + } + if pageSize > 0 { + req = req.PageSize(pageSize) + } + return req +} + +func fetchArtifactories(ctx context.Context, model *inputModel, apiClient *registry.APIClient) ([]registry.Artifactory, error) { + var pageToken string + artifactories := make([]registry.Artifactory, 0) + received := int64(0) + limit := int64(math.MaxInt64) + if model.Limit != nil { + limit = *model.Limit + } + for { + want := min(int64(maxPageSize), limit-received) + request := buildRequest(ctx, model, apiClient, pageToken, int32(want)) + response, err := request.Execute() + if err != nil { + return nil, fmt.Errorf("get artifactories: %w", err) + } + if response == nil { + return nil, fmt.Errorf("get artifactories: empty response") + } + if response.Artifactories != nil { + artifactories = append(artifactories, response.Artifactories...) + } + pageToken = "" + if response.NextPageToken != nil { + pageToken = *response.NextPageToken + } + received += want + if pageToken == "" || received >= limit { + break + } + } + if model.Limit != nil && len(artifactories) > int(*model.Limit) { + artifactories = artifactories[:*model.Limit] + } + return artifactories, nil } func outputResult(p *print.Printer, outputFormat, projectLabel string, artifactories []registry.Artifactory) error { diff --git a/internal/cmd/registry/artifactory/list/list_test.go b/internal/cmd/registry/artifactory/list/list_test.go index e0b73b7ed..3c8b12f7e 100644 --- a/internal/cmd/registry/artifactory/list/list_test.go +++ b/internal/cmd/registry/artifactory/list/list_test.go @@ -54,7 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *registry.ApiListArtifactoriesRequest)) registry.ApiListArtifactoriesRequest { - request := testClient.DefaultAPI.ListArtifactories(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.ListArtifactories(testCtx, testProjectId, testRegion).PageSize(maxPageSize) for _, mod := range mods { mod(&request) } @@ -137,18 +137,30 @@ func TestBuildRequest(t *testing.T) { tests := []struct { description string model *inputModel + pageToken string + pageSize int32 expectedRequest registry.ApiListArtifactoriesRequest }{ { description: "base", model: fixtureInputModel(), + pageSize: maxPageSize, expectedRequest: fixtureRequest(), }, + { + description: "with page token", + model: fixtureInputModel(), + pageToken: "next-token", + pageSize: maxPageSize, + expectedRequest: fixtureRequest(func(request *registry.ApiListArtifactoriesRequest) { + *request = request.NextPageToken("next-token") + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, tt.model, testClient) + request := buildRequest(testCtx, tt.model, testClient, tt.pageToken, tt.pageSize) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest, registry.DefaultAPIService{}), From a7a41c4d3a886771f940af770aac89891fd7240c Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:45:51 +0200 Subject: [PATCH 13/17] feat(config): support registry-custom-endpoint in config set and unset --- docs/stackit_config_set.md | 1 + docs/stackit_config_unset.md | 1 + internal/cmd/config/profile/import/template/profile.json | 1 + internal/cmd/config/set/set.go | 4 ++++ internal/cmd/config/unset/unset.go | 7 +++++++ internal/cmd/config/unset/unset_test.go | 3 +++ 6 files changed, 17 insertions(+) diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 9f21f4095..26588849f 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -54,6 +54,7 @@ stackit config set [flags] --postgresflex-custom-endpoint string PostgreSQL Flex API base URL, used in calls to this API --rabbitmq-custom-endpoint string RabbitMQ API base URL, used in calls to this API --redis-custom-endpoint string Redis API base URL, used in calls to this API + --registry-custom-endpoint string Registry API base URL, used in calls to this API --resource-manager-custom-endpoint string Resource Manager API base URL, used in calls to this API --runcommand-custom-endpoint string Run Command API base URL, used in calls to this API --secrets-manager-custom-endpoint string Secrets Manager API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 5f64ad2e4..c99f5869b 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -56,6 +56,7 @@ stackit config unset [flags] --rabbitmq-custom-endpoint RabbitMQ API base URL. If unset, uses the default base URL --redis-custom-endpoint Redis API base URL. If unset, uses the default base URL --region Region + --registry-custom-endpoint Registry API base URL. If unset, uses the default base URL --resource-manager-custom-endpoint Resource Manager API base URL. If unset, uses the default base URL --runcommand-custom-endpoint Server Command base URL. If unset, uses the default base URL --secrets-manager-custom-endpoint Secrets Manager API base URL. If unset, uses the default base URL diff --git a/internal/cmd/config/profile/import/template/profile.json b/internal/cmd/config/profile/import/template/profile.json index ed2702e7e..c5c056795 100644 --- a/internal/cmd/config/profile/import/template/profile.json +++ b/internal/cmd/config/profile/import/template/profile.json @@ -20,6 +20,7 @@ "project_name": "", "rabbitmq_custom_endpoint": "", "redis_custom_endpoint": "", + "registry_custom_endpoint": "", "resource_manager_custom_endpoint": "", "runcommand_custom_endpoint": "", "secrets_manager_custom_endpoint": "", diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 194291a3d..8a13e0c9e 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -57,6 +57,7 @@ const ( sfsCustomEndpointFlag = "sfs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + registryCustomEndpointFlag = "registry-custom-endpoint" ) type inputModel struct { @@ -180,6 +181,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(registryCustomEndpointFlag, "", "Registry API base URL, used in calls to this API") err := viper.BindPFlag(config.SessionTimeLimitKey, cmd.Flags().Lookup(sessionTimeLimitFlag)) cobra.CheckErr(err) @@ -256,6 +258,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.RegistryCustomEndpointKey, cmd.Flags().Lookup(registryCustomEndpointFlag)) + 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 5aff526eb..d5314e51c 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -60,6 +60,7 @@ const ( logsCustomEndpointFlag = "logs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + registryCustomEndpointFlag = "registry-custom-endpoint" ) var ( @@ -113,6 +114,7 @@ type inputModel struct { LogsCustomEndpoint bool CDNCustomEndpoint bool VpnCustomEndpoint bool + RegistryCustomEndpoint bool } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -266,6 +268,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.VpnCustomEndpoint { viper.Set(config.VPNCustomEndpointKey, "") } + if model.RegistryCustomEndpoint { + viper.Set(config.RegistryCustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -324,6 +329,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(registryCustomEndpointFlag, false, "Registry API base URL. If unset, uses the default base URL") } func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { @@ -373,6 +379,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), + RegistryCustomEndpoint: flags.FlagToBoolValue(p, cmd, registryCustomEndpointFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 46a41ee21..ccdad702e 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -52,6 +52,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool logsCustomEndpointFlag: true, cdnCustomEndpointFlag: true, vpnCustomEndpointFlag: true, + registryCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -102,6 +103,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { LogsCustomEndpoint: true, CDNCustomEndpoint: true, VpnCustomEndpoint: true, + RegistryCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -168,6 +170,7 @@ func TestParseInput(t *testing.T) { model.LogsCustomEndpoint = false model.CDNCustomEndpoint = false model.VpnCustomEndpoint = false + model.RegistryCustomEndpoint = false }), }, { From ae860567e547f8774e8684ab042ede02141b618e Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:46:10 +0200 Subject: [PATCH 14/17] fix(registry): add nil check and unit tests for GetArtifactoryName --- internal/pkg/services/registry/utils/utils.go | 3 + .../pkg/services/registry/utils/utils_test.go | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 internal/pkg/services/registry/utils/utils_test.go diff --git a/internal/pkg/services/registry/utils/utils.go b/internal/pkg/services/registry/utils/utils.go index 6e7136074..905569586 100644 --- a/internal/pkg/services/registry/utils/utils.go +++ b/internal/pkg/services/registry/utils/utils.go @@ -12,5 +12,8 @@ func GetArtifactoryName(ctx context.Context, apiClient registry.DefaultAPI, proj if err != nil { return "", fmt.Errorf("get artifactory: %w", err) } + if resp == nil { + return "", fmt.Errorf("get artifactory: empty response") + } return resp.Name, nil } diff --git a/internal/pkg/services/registry/utils/utils_test.go b/internal/pkg/services/registry/utils/utils_test.go new file mode 100644 index 000000000..6f069a727 --- /dev/null +++ b/internal/pkg/services/registry/utils/utils_test.go @@ -0,0 +1,88 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var ( + testProjectId = uuid.NewString() + testArtifactoryId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testArtifactoryName = "my-artifactory" +) + +type registryClientMocked struct { + getArtifactoryFails bool + getArtifactoryResp *registry.Artifactory +} + +func (m *registryClientMocked) newMock() registry.DefaultAPI { + return registry.DefaultAPIServiceMock{ + GetArtifactoryExecuteMock: utils.Ptr(func(_ registry.ApiGetArtifactoryRequest) (*registry.Artifactory, error) { + if m.getArtifactoryFails { + return nil, fmt.Errorf("could not get artifactory") + } + return m.getArtifactoryResp, nil + }), + } +} + +func TestGetArtifactoryName(t *testing.T) { + tests := []struct { + description string + getArtifactoryFails bool + getArtifactoryResp *registry.Artifactory + isValid bool + expectedOutput string + }{ + { + description: "base", + getArtifactoryResp: ®istry.Artifactory{ + Name: testArtifactoryName, + }, + isValid: true, + expectedOutput: testArtifactoryName, + }, + { + description: "get artifactory fails", + getArtifactoryFails: true, + isValid: false, + }, + { + description: "empty response (nil)", + getArtifactoryResp: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := ®istryClientMocked{ + getArtifactoryFails: tt.getArtifactoryFails, + getArtifactoryResp: tt.getArtifactoryResp, + } + + output, err := GetArtifactoryName(context.Background(), client.newMock(), testProjectId, testRegion, testArtifactoryId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input: %v", err) + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if tt.isValid && output != tt.expectedOutput { + t.Errorf("expected output %q, got %q", tt.expectedOutput, output) + } + }) + } +} From 2733359911364be9441e65565e6802a1a4965280 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:46:29 +0200 Subject: [PATCH 15/17] style(registry): format creation timestamp with time.DateTime in describe --- internal/cmd/registry/artifactory/describe/describe.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cmd/registry/artifactory/describe/describe.go b/internal/cmd/registry/artifactory/describe/describe.go index def54a318..52f0ba375 100644 --- a/internal/cmd/registry/artifactory/describe/describe.go +++ b/internal/cmd/registry/artifactory/describe/describe.go @@ -3,6 +3,7 @@ package describe import ( "context" "fmt" + "time" "github.com/spf13/cobra" registry "github.com/stackitcloud/stackit-sdk-go/services/registry/v1api" @@ -105,7 +106,7 @@ func outputResult(p *print.Printer, outputFormat string, artifactory *registry.A table.AddSeparator() table.AddRow("CONSUMED QUOTA BYTES", artifactory.ConsumedQuotaBytes) table.AddSeparator() - table.AddRow("CREATED AT", artifactory.CreateTime.String()) + table.AddRow("CREATED AT", artifactory.CreateTime.Format(time.DateTime)) if artifactory.Proxy != nil { table.AddSeparator() table.AddRow("PROXY URL", artifactory.Proxy.Url) From 918dcbf361ad391bc3db4b784ea79b6c966d42c4 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 11:48:13 +0200 Subject: [PATCH 16/17] test(registry): add unit test coverage for artifactory update flags --- .../artifactory/update/update_test.go | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/cmd/registry/artifactory/update/update_test.go b/internal/cmd/registry/artifactory/update/update_test.go index 55159f5fe..f0d16e7cc 100644 --- a/internal/cmd/registry/artifactory/update/update_test.go +++ b/internal/cmd/registry/artifactory/update/update_test.go @@ -23,11 +23,14 @@ const ( type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} - testProjectId = uuid.NewString() - testArtifactoryId = uuid.NewString() - testProxyUrl = "https://registry-1.docker.io" + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = ®istry.APIClient{DefaultAPI: ®istry.DefaultAPIService{}} + testProjectId = uuid.NewString() + testArtifactoryId = uuid.NewString() + testProxyUrl = "https://registry-1.docker.io" + testProxyDescription = "docker hub proxy" + testProxyAccessKey = "my-key" + testProxyAccessSecret = "my-secret" ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -86,6 +89,50 @@ func TestParseInput(t *testing.T) { isValid: true, expectedModel: fixtureInputModel(), }, + { + description: "with description", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, proxyUrlFlag) + flagValues[proxyDescriptionFlag] = testProxyDescription + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = nil + model.ProxyDescription = utils.Ptr(testProxyDescription) + }), + }, + { + description: "with credentials", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, proxyUrlFlag) + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + flagValues[proxyAccessSecretFlag] = testProxyAccessSecret + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = nil + model.ProxyAccessKey = utils.Ptr(testProxyAccessKey) + model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) + }), + }, + { + description: "proxy access key without secret", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + }), + isValid: false, + }, + { + description: "proxy access secret without key", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyAccessSecretFlag] = testProxyAccessSecret + }), + isValid: false, + }, { description: "no flags set", argValues: []string{testArtifactoryId}, @@ -138,6 +185,40 @@ func TestBuildRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "with description", + model: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = nil + model.ProxyDescription = utils.Ptr(testProxyDescription) + }), + expectedRequest: fixtureRequest(func(request *registry.ApiPatchArtifactoryRequest) { + *request = testClient.DefaultAPI.PatchArtifactory(testCtx, testProjectId, testRegion, testArtifactoryId). + PatchArtifactoryPayload(registry.PatchArtifactoryPayload{ + Proxy: ®istry.UpdateProxyRequest{ + Description: utils.Ptr(testProxyDescription), + }, + }) + }), + }, + { + description: "with credentials", + model: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = nil + model.ProxyAccessKey = utils.Ptr(testProxyAccessKey) + model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) + }), + expectedRequest: fixtureRequest(func(request *registry.ApiPatchArtifactoryRequest) { + *request = testClient.DefaultAPI.PatchArtifactory(testCtx, testProjectId, testRegion, testArtifactoryId). + PatchArtifactoryPayload(registry.PatchArtifactoryPayload{ + Proxy: ®istry.UpdateProxyRequest{ + Credentials: ®istry.Credentials{ + AccessKey: testProxyAccessKey, + AccessSecret: testProxyAccessSecret, + }, + }, + }) + }), + }, } for _, tt := range tests { From e1e9254d04360ea3df6e9ad1f0f1f13f9dcca704 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Fri, 23 Oct 2026 12:37:06 +0200 Subject: [PATCH 17/17] chore: remove code review report for STACKITCRE-382 --- .report/STACKITCRE-382.md | 162 -------------------------------------- 1 file changed, 162 deletions(-) delete mode 100644 .report/STACKITCRE-382.md diff --git a/.report/STACKITCRE-382.md b/.report/STACKITCRE-382.md deleted file mode 100644 index 5992eb82e..000000000 --- a/.report/STACKITCRE-382.md +++ /dev/null @@ -1,162 +0,0 @@ -# Code Review Report - STACKITCRE-382 - -**Rama analizada:** `feat/STACKITCRE-382-registry` (comparada contra `origin/main`) -**Fecha:** 2026-10-23 -**Revisor:** Senior Code Reviewer - ---- - -## Resumen Ejecutivo - -Se revisó la implementación de los nuevos comandos para Container Registry (`stackit registry artifactory [create|delete|describe|list|update]`), la configuración del cliente API y la documentación generada. - -La estructura general y la integración con Cobra y el SDK de STACKIT son sólidas y siguen los patrones base de `stackit-cli`. Todos los hallazgos de código (ítems 1 al 8) han sido corregidos y validados con tests unitarios y linter. El ítem 9 (enmienda de commit) queda reservado para la etapa de commit/rebase interactivo cuando el usuario lo autorice. - ---- - -## Estado de Resolución - -- [x] **Ítem 1 (Major):** Validación conjunta obligatoria de credenciales proxy (`MarkFlagsRequiredTogether`) en `update.go`. -- [x] **Ítem 2 (Major):** Manejo seguro de secretos con `flags.SecretFlag`, `SecretFlagToStringPointer` y `json:"-"` en `create.go` y `update.go`. -- [x] **Ítem 3 (Major):** Paginación completa con seguimiento de `NextPageToken` y respeto de `--limit` en `list.go`. -- [x] **Ítem 4 (Minor):** Soporte para `--registry-custom-endpoint` en `config set`, `config unset` y `profile.json`. -- [x] **Ítem 5 (Minor):** Verificación de respuesta nil en `GetArtifactoryName` y suite de tests unitarios en `utils_test.go`. -- [x] **Ítem 6 (Minor):** Captura de entidad actualizada retornada por los `WaitHandler` en `create.go` y `update.go`. -- [x] **Ítem 7 (Suggestion):** Formato homogéneo de fecha `time.DateTime` en la tabla de `describe.go`. -- [x] **Ítem 8 (Suggestion):** Ampliación de la cobertura de pruebas unitarias en `update_test.go`. -- [ ] **Ítem 9 (Suggestion):** Corrección tipográfica en mensaje de commit `92766842` (`chroe` -> `chore`) pendiente de rebase/amend cuando el usuario lo indique. - ---- - -## Hallazgos - -### 1. Descarte silencioso de credenciales por omisión de `MarkFlagsRequiredTogether` -- **Severidad:** `Major` -- **Ubicación:** [update.go:103-108](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L103-L108), [update.go:148-153](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L148-L153) -- **Problema concreto:** En `update.go`, no se vinculó `cmd.MarkFlagsRequiredTogether(proxyAccessKeyFlag, proxyAccessSecretFlag)` como sí se hizo en `create.go`. Si un usuario ejecuta el comando especificando únicamente `--proxy-access-key` (o `--proxy-access-secret`), la validación pasa con éxito. No obstante, en `buildRequest` la condición `model.ProxyAccessKey != nil && model.ProxyAccessSecret != nil` se evalúa como falsa, por lo que `proxyReq.Credentials` queda `nil`. La petición se envía a la API sin credenciales y el CLI emite `"Updated artifactory..."`, descartando silenciosamente la credencial provista por el usuario sin ningún aviso o error. -- **Sugerencia de corrección:** - Configurar la regla de flags en `configureFlags`: - ```go - func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(proxyUrlFlag, "", "Upstream registry URL to proxy") - cmd.Flags().String(proxyDescriptionFlag, "", "Description of the proxy registry") - cmd.Flags().String(proxyAccessKeyFlag, "", "Access key for upstream proxy registry authentication") - cmd.Flags().String(proxyAccessSecretFlag, "", "Access secret for upstream proxy registry authentication") - - cmd.MarkFlagsRequiredTogether(proxyAccessKeyFlag, proxyAccessSecretFlag) - } - ``` - ---- - -### 2. Exposición de secretos en línea de comandos, logs de depuración y falta de soporte para `@file`/`stdin` -- **Severidad:** `Major` -- **Ubicación:** [create.go:120](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/create/create.go#L120), [update.go:107](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L107), [create.go:33-41](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/create/create.go#L33-L41) -- **Problema concreto:** El flag `--proxy-access-secret` se registró como un flag string plano ordinario (`cmd.Flags().String`). En todo el CLI (`alb`, `cdn`, `load-balancer`, `intake`), los secretos deben configurarse mediante `flags.SecretFlag(proxyAccessSecretFlag, params)` y extraerse con `flags.SecretFlagToStringPointer`. La implementación actual causa: - 1. Exposición del secreto en el historial de shell y en la lista de procesos (`ps aux`). - 2. Imposibilidad de suministrar el secreto de forma segura mediante un archivo (`--proxy-access-secret @./secret.txt`) o stdin. - 3. Fuga del secreto en texto plano en los logs de depuración: `p.DebugInputModel(model)` convierte `inputModel` a JSON y luego a mapa; al ser un campo exportado sin ofuscación, el secreto se imprime en los logs de debug. -- **Sugerencia de corrección:** - Usar `flags.SecretFlag` y proteger el modelo: - ```go - // En configureFlags(cmd *cobra.Command, params *types.CmdParams): - secretFlag := flags.SecretFlag(proxyAccessSecretFlag, params) - cmd.Flags().Var(secretFlag, proxyAccessSecretFlag, secretFlag.Usage()) - - // En parseInput: - ProxyAccessSecret: flags.SecretFlagToStringPointer(p, cmd, proxyAccessSecretFlag), - - // En inputModel: - ProxyAccessSecret *string `json:"-"` - ``` - ---- - -### 3. Falta de paginación en `list` (pérdida de resultados) -- **Severidad:** `Major` -- **Ubicación:** [list.go:62-73](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/list/list.go#L62-L73) -- **Problema concreto:** `ApiListArtifactoriesRequest` admite `NextPageToken` y `PageSize`, y la API responde con un `NextPageToken`. Actualmente, `list.go` solo ejecuta un único llamado: - ```go - req := buildRequest(ctx, model, apiClient) - resp, err := req.Execute() - ``` - Si el proyecto cuenta con más elementos que el tamaño de página por defecto o si el usuario indica un `--limit` superior a una sola página, los elementos posteriores nunca se obtienen ni se muestran, reportando una lista incompleta de artifactories. -- **Sugerencia de corrección:** - Implementar el bucle de paginación siguiendo `NextPageToken` hasta alcanzar el `limit` o hasta que no haya más páginas, siguiendo el patrón de servicios como `beta/cdn/distribution/list/list.go`. - ---- - -### 4. Inconsistencia de configuración: endpoint de registry no expuesto en `config set` / `config unset` -- **Severidad:** `Minor` -- **Ubicación:** [config.go:62](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/pkg/config/config.go#L62), [set.go:58](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/config/set/set.go#L58), [unset.go:61](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/config/unset/unset.go#L61) -- **Problema concreto:** Se añadió `RegistryCustomEndpointKey` a `internal/pkg/config/config.go` y se consume en `internal/pkg/services/registry/client/client.go`. Sin embargo, no se crearon los flags correspondientes `--registry-custom-endpoint` ni en `stackit config set` ni en `stackit config unset`, ni se agregó al template de perfiles `profile.json`. En consecuencia, ningún usuario puede configurar ni eliminar dicho endpoint mediante los comandos estándar de configuración. -- **Sugerencia de corrección:** - Agregar el flag `registryCustomEndpointFlag = "registry-custom-endpoint"` y su enlace con viper en `internal/cmd/config/set/set.go` e `internal/cmd/config/unset/unset.go`. - ---- - -### 5. Falta de verificación de nil en `GetArtifactoryName` y ausencia total de `utils_test.go` -- **Severidad:** `Minor` -- **Ubicación:** [utils.go:10-15](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/pkg/services/registry/utils/utils.go#L10-L15) -- **Problema concreto:** La función `GetArtifactoryName` desreferencia directamente `resp.Name` tras el llamado API sin verificar previamente si `resp != nil`. Además, el paquete `internal/pkg/services/registry/utils` no cuenta con archivo de tests unitarios (`utils_test.go`), a diferencia de prácticamente todos los demás servicios bajo `internal/pkg/services/*/utils/`. -- **Sugerencia de corrección:** - Agregar validación de nil y crear `utils_test.go` con mocks del servicio: - ```go - func GetArtifactoryName(ctx context.Context, apiClient registry.DefaultAPI, projectId, region, artifactoryId string) (string, error) { - resp, err := apiClient.GetArtifactory(ctx, projectId, region, artifactoryId).Execute() - if err != nil { - return "", fmt.Errorf("get artifactory: %w", err) - } - if resp == nil { - return "", fmt.Errorf("get artifactory: empty response") - } - return resp.Name, nil - } - ``` - ---- - -### 6. Descarte de la respuesta actualizada del `WaitHandler` en modo sincrónico -- **Severidad:** `Minor` -- **Ubicación:** [create.go:99](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/create/create.go#L99), [update.go:88](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update.go#L88) -- **Problema concreto:** Al completarse la espera (`wait.CreateArtifactoryWaitHandler` o `wait.UpdateArtifactoryWaitHandler`), el handler retorna la entidad `*registry.Artifactory` con su estado actualizado. Sin embargo, el código ignora dicho retorno (`_, err = ...`) y le pasa a `outputResult` el `resp` inicial devuelto por la llamada de creación/actualización. En formatos de salida como JSON (`-o json`), la respuesta puede reflejar estados intermedios en vez del estado consolidado tras la espera. -- **Sugerencia de corrección:** - Capturar la respuesta del wait handler: - ```go - err := spinner.Run(params.Printer, "Creating artifactory", func() error { - resp, err = wait.CreateArtifactoryWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, artifactoryId).WaitWithContext(ctx) - return err - }) - ``` - ---- - -### 7. Formato crudo de fecha en salida de tabla de `describe` -- **Severidad:** `Suggestion` -- **Ubicación:** [describe.go:108](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/describe/describe.go#L108) -- **Problema concreto:** `table.AddRow("CREATED AT", artifactory.CreateTime.String())` invoca el método por defecto de Go `time.Time.String()`, imprimiendo timestamps con sufijos de zona y reloj monotónico desalineados con el resto de la CLI. En toda la aplicación se utiliza `artifactory.CreateTime.Format(time.DateTime)` o `time.RFC3339`. -- **Sugerencia de corrección:** - ```go - table.AddRow("CREATED AT", artifactory.CreateTime.Format(time.DateTime)) - ``` - ---- - -### 8. Cobertura incompleta de casos de prueba en `update_test.go` -- **Severidad:** `Suggestion` -- **Ubicación:** [update_test.go:74-140](file:///Users/catalan/Project/STACKIT/stackit-cli/internal/cmd/registry/artifactory/update/update_test.go#L74-L140) -- **Problema concreto:** La suite de pruebas de `update` solo cubre el escenario en el que se modifica `--proxy-url`. No existen casos de prueba para verificar: - - Actualización de `--proxy-description`. - - Actualización con credenciales (`--proxy-access-key` y `--proxy-access-secret`). - - Validación de combinaciones inválidas (por ejemplo, clave sin secreto). -- **Sugerencia de corrección:** - Añadir casos de prueba para cada bandera de proxy en `TestParseInput` y `TestBuildRequest`. - ---- - -### 9. Typo en mensaje de commit (Conventional Commits) -- **Severidad:** `Suggestion` -- **Ubicación:** Commit `92766842` -- **Problema concreto:** El commit `92766842` contiene un error tipográfico en el tipo de cambio: `chroe(cli): revert bump` en lugar de `chore(cli): revert bump`. -- **Sugerencia de corrección:** - Corregir mediante rebase interactivo (`git commit --amend`) antes de integrar a la rama principal.