From bdae505f5aeec2433fa22070b183208291855c52 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Wed, 21 Oct 2026 12:35:39 +0200 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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. From e4d47e9372b447466f13bd8889584774c2906c45 Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Tue, 3 Nov 2026 20:36:41 +0100 Subject: [PATCH 18/20] fix(registry): address PR #1693 review comments for STACKITCRE-382 - Migrate optional secret flag resolution to flags.ResolveSecretFlag in create and update commands to prevent unintended stdin prompts when omitted - Update command examples in create.go to read secrets from stdin (-) or file (@) instead of deprecated literal strings - Regenerate registry create and update documentation - Expand unit test coverage in delete_test.go, describe_test.go, and update_test.go for missing args, missing flags, and empty/invalid artifactory UUIDs - Add secret resolution test cases using fstest.MapFS and verify rejection of literal secret values - Add missing unit test case for unsetting registry-custom-endpoint in unset_test.go Relates to STACKITCRE-382 --- docs/stackit_registry_artifactory_create.md | 9 ++- docs/stackit_registry_artifactory_update.md | 2 +- internal/cmd/config/unset/unset_test.go | 10 +++ .../cmd/registry/artifactory/create/create.go | 13 +++- .../artifactory/create/create_test.go | 46 +++++++++++- .../artifactory/delete/delete_test.go | 30 ++++++++ .../artifactory/describe/describe_test.go | 30 ++++++++ .../cmd/registry/artifactory/update/update.go | 5 +- .../artifactory/update/update_test.go | 74 ++++++++++++++++++- 9 files changed, 206 insertions(+), 13 deletions(-) diff --git a/docs/stackit_registry_artifactory_create.md b/docs/stackit_registry_artifactory_create.md index 2707c3efc..3bbd76561 100644 --- a/docs/stackit_registry_artifactory_create.md +++ b/docs/stackit_registry_artifactory_create.md @@ -19,8 +19,11 @@ stackit registry artifactory create [flags] 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 + Create a proxy-cache artifactory with credentials, reading secret from stdin + $ 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 - + + Create a proxy-cache artifactory with credentials, providing the path to a file with the secret + $ 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 @./secret.txt ``` ### Options @@ -29,7 +32,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 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-access-secret secret Proxy-Access-Secret. Use @ to read from a file (example: @./secret.txt), or - to read from stdin (hidden input on a terminal). Literal values are not supported. --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 71a370967..bf244f340 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 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-access-secret secret Proxy-Access-Secret. Use @ to read from a file (example: @./secret.txt), or - to read from stdin (hidden input on a terminal). Literal values are not supported. --proxy-description string Description of the proxy registry --proxy-url string Upstream registry URL to proxy ``` diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index b6649622d..1c431d6d7 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -296,6 +296,16 @@ func TestParseInput(t *testing.T) { model.KMSCustomEndpoint = false }), }, + { + description: "registry custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[registryCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.RegistryCustomEndpoint = false + }), + }, { description: "service account custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/cmd/registry/artifactory/create/create.go b/internal/cmd/registry/artifactory/create/create.go index 776a30690..148378456 100644 --- a/internal/cmd/registry/artifactory/create/create.go +++ b/internal/cmd/registry/artifactory/create/create.go @@ -56,8 +56,12 @@ func NewCmd(params *types.CmdParams) *cobra.Command { "$ 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", + `Create a proxy-cache artifactory with credentials, reading secret from stdin`, + "$ 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 -", + ), + examples.NewExample( + `Create a proxy-cache artifactory with credentials, providing the path to a file with the secret`, + "$ 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 @./secret.txt", ), ), RunE: func(cmd *cobra.Command, args []string) error { @@ -142,7 +146,10 @@ 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.SecretFlagToStringPointer(p, cmd, proxyAccessSecretFlag) + proxySecret, err := flags.ResolveSecretFlag(p, cmd, proxyAccessSecretFlag) + if err != nil { + return nil, err + } hasProxyDetails := proxyDesc != nil || proxyKey != nil || proxySecret != nil if hasProxyDetails && (proxyUrl == nil || proxyRegistryType == nil) { diff --git a/internal/cmd/registry/artifactory/create/create_test.go b/internal/cmd/registry/artifactory/create/create_test.go index fef8e7606..05a48d254 100644 --- a/internal/cmd/registry/artifactory/create/create_test.go +++ b/internal/cmd/registry/artifactory/create/create_test.go @@ -2,16 +2,20 @@ package create import ( "context" + "strings" "testing" + "testing/fstest" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/spf13/cobra" 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/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -32,6 +36,7 @@ var ( testProxyDescription = "Docker Hub proxy cache" testProxyAccessKey = "my-user" testProxyAccessSecret = "my-secret" + testSecretFile = "secret.txt" ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -74,6 +79,7 @@ func fixtureRequest(mods ...func(request *registry.ApiCreateArtifactoryRequest)) func TestParseInput(t *testing.T) { tests := []struct { + stdin string description string flagValues map[string]string isValid bool @@ -104,7 +110,7 @@ func TestParseInput(t *testing.T) { flagValues[proxyRegistryTypeFlag] = testProxyRegistryType flagValues[proxyDescriptionFlag] = testProxyDescription flagValues[proxyAccessKeyFlag] = testProxyAccessKey - flagValues[proxyAccessSecretFlag] = testProxyAccessSecret + flagValues[proxyAccessSecretFlag] = "@" + testSecretFile }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { @@ -115,6 +121,36 @@ func TestParseInput(t *testing.T) { model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) }), }, + { + description: "with proxy full - secret from stdin", + stdin: testProxyAccessSecret + "\n", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + flagValues[proxyDescriptionFlag] = testProxyDescription + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + flagValues[proxyAccessSecretFlag] = "-" + }), + 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: "with proxy full - literal secret rejected", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + flagValues[proxyDescriptionFlag] = testProxyDescription + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + flagValues[proxyAccessSecretFlag] = "literal-secret" + }), + isValid: false, + }, { description: "proxy url without registry type", flagValues: fixtureFlagValues(func(flagValues map[string]string) { @@ -177,7 +213,13 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + testutils.TestParseInput(t, func(params *types.CmdParams) *cobra.Command { + params.Fs = fstest.MapFS{ + testSecretFile: {Data: []byte(testProxyAccessSecret)}, + } + params.Printer.StdIn = strings.NewReader(tt.stdin) + return NewCmd(params) + }, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/registry/artifactory/delete/delete_test.go b/internal/cmd/registry/artifactory/delete/delete_test.go index fb9e257fb..edfbfe208 100644 --- a/internal/cmd/registry/artifactory/delete/delete_test.go +++ b/internal/cmd/registry/artifactory/delete/delete_test.go @@ -100,6 +100,36 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: []string{testArtifactoryId}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "artifactory id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "artifactory id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, } for _, tt := range tests { diff --git a/internal/cmd/registry/artifactory/describe/describe_test.go b/internal/cmd/registry/artifactory/describe/describe_test.go index bd26e096c..53513da8b 100644 --- a/internal/cmd/registry/artifactory/describe/describe_test.go +++ b/internal/cmd/registry/artifactory/describe/describe_test.go @@ -102,6 +102,36 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: []string{testArtifactoryId}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "artifactory id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "artifactory id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, } for _, tt := range tests { diff --git a/internal/cmd/registry/artifactory/update/update.go b/internal/cmd/registry/artifactory/update/update.go index 8db95bd2e..5ff5ebf94 100644 --- a/internal/cmd/registry/artifactory/update/update.go +++ b/internal/cmd/registry/artifactory/update/update.go @@ -126,7 +126,10 @@ 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.SecretFlagToStringPointer(p, cmd, proxyAccessSecretFlag) + proxySecret, err := flags.ResolveSecretFlag(p, cmd, proxyAccessSecretFlag) + if err != nil { + return nil, err + } if proxyUrl == nil && proxyDesc == nil && proxyKey == nil && proxySecret == nil { return nil, &cliErr.EmptyUpdateError{} diff --git a/internal/cmd/registry/artifactory/update/update_test.go b/internal/cmd/registry/artifactory/update/update_test.go index f0d16e7cc..d5add9dca 100644 --- a/internal/cmd/registry/artifactory/update/update_test.go +++ b/internal/cmd/registry/artifactory/update/update_test.go @@ -2,16 +2,20 @@ package update import ( "context" + "strings" "testing" + "testing/fstest" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/spf13/cobra" 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/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -31,6 +35,7 @@ var ( testProxyDescription = "docker hub proxy" testProxyAccessKey = "my-key" testProxyAccessSecret = "my-secret" + testSecretFile = "secret.txt" ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -76,6 +81,7 @@ func fixtureRequest(mods ...func(request *registry.ApiPatchArtifactoryRequest)) func TestParseInput(t *testing.T) { tests := []struct { + stdin string description string argValues []string flagValues map[string]string @@ -108,7 +114,7 @@ func TestParseInput(t *testing.T) { flagValues: fixtureFlagValues(func(flagValues map[string]string) { delete(flagValues, proxyUrlFlag) flagValues[proxyAccessKeyFlag] = testProxyAccessKey - flagValues[proxyAccessSecretFlag] = testProxyAccessSecret + flagValues[proxyAccessSecretFlag] = "@" + testSecretFile }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { @@ -117,6 +123,32 @@ func TestParseInput(t *testing.T) { model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) }), }, + { + description: "with credentials - secret from stdin", + stdin: testProxyAccessSecret + "\n", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, proxyUrlFlag) + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + flagValues[proxyAccessSecretFlag] = "-" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = nil + model.ProxyAccessKey = utils.Ptr(testProxyAccessKey) + model.ProxyAccessSecret = utils.Ptr(testProxyAccessSecret) + }), + }, + { + description: "with credentials - literal secret rejected", + argValues: []string{testArtifactoryId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, proxyUrlFlag) + flagValues[proxyAccessKeyFlag] = testProxyAccessKey + flagValues[proxyAccessSecretFlag] = "literal-secret" + }), + isValid: false, + }, { description: "proxy access key without secret", argValues: []string{testArtifactoryId}, @@ -129,7 +161,7 @@ func TestParseInput(t *testing.T) { description: "proxy access secret without key", argValues: []string{testArtifactoryId}, flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[proxyAccessSecretFlag] = testProxyAccessSecret + flagValues[proxyAccessSecretFlag] = "@" + testSecretFile }), isValid: false, }, @@ -165,11 +197,47 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: []string{testArtifactoryId}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "artifactory id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "artifactory id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + 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) + testutils.TestParseInput(t, func(params *types.CmdParams) *cobra.Command { + params.Fs = fstest.MapFS{ + testSecretFile: {Data: []byte(testProxyAccessSecret)}, + } + params.Printer.StdIn = strings.NewReader(tt.stdin) + return NewCmd(params) + }, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } From 5569dd2bc9c61c968a8abf3788c81131bc7468cb Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Tue, 3 Nov 2026 21:15:50 +0100 Subject: [PATCH 19/20] feat(registry): add replication-adapter list command - Add 'stackit registry replication-adapter list' command to list available replication adapters - Support client-side --limit flag and JSON/YAML table formatting - Add unit tests covering argument parsing, request building, and output formatting - Generate markdown documentation for the new command Relates to STACKITCRE-382 --- docs/stackit_registry.md | 1 + docs/stackit_registry_replication-adapter.md | 34 +++ ...ackit_registry_replication-adapter_list.md | 47 ++++ internal/cmd/registry/registry.go | 2 + .../registry/replication-adapter/list/list.go | 146 +++++++++++ .../replication-adapter/list/list_test.go | 227 ++++++++++++++++++ .../replication_adapter.go | 27 +++ 7 files changed, 484 insertions(+) create mode 100644 docs/stackit_registry_replication-adapter.md create mode 100644 docs/stackit_registry_replication-adapter_list.md create mode 100644 internal/cmd/registry/replication-adapter/list/list.go create mode 100644 internal/cmd/registry/replication-adapter/list/list_test.go create mode 100644 internal/cmd/registry/replication-adapter/replication_adapter.go diff --git a/docs/stackit_registry.md b/docs/stackit_registry.md index b785e45b4..2107389a2 100644 --- a/docs/stackit_registry.md +++ b/docs/stackit_registry.md @@ -31,4 +31,5 @@ stackit registry [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit registry artifactory](./stackit_registry_artifactory.md) - Provides functionality for Container Registry artifactories +* [stackit registry replication-adapter](./stackit_registry_replication-adapter.md) - Provides functionality for Container Registry replication adapters diff --git a/docs/stackit_registry_replication-adapter.md b/docs/stackit_registry_replication-adapter.md new file mode 100644 index 000000000..c3014fbc8 --- /dev/null +++ b/docs/stackit_registry_replication-adapter.md @@ -0,0 +1,34 @@ +## stackit registry replication-adapter + +Provides functionality for Container Registry replication adapters + +### Synopsis + +Provides functionality for Container Registry replication adapters. + +``` +stackit registry replication-adapter [flags] +``` + +### Options + +``` + -h, --help Help for "stackit registry replication-adapter" +``` + +### 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 replication-adapter list](./stackit_registry_replication-adapter_list.md) - Lists all Container Registry replication adapters + diff --git a/docs/stackit_registry_replication-adapter_list.md b/docs/stackit_registry_replication-adapter_list.md new file mode 100644 index 000000000..605fa42e3 --- /dev/null +++ b/docs/stackit_registry_replication-adapter_list.md @@ -0,0 +1,47 @@ +## stackit registry replication-adapter list + +Lists all Container Registry replication adapters + +### Synopsis + +Lists all available adapters for replication to/from other registries. + +``` +stackit registry replication-adapter list [flags] +``` + +### Examples + +``` + List all replication adapters + $ stackit registry replication-adapter list + + List all replication adapters in JSON format + $ stackit registry replication-adapter list --output-format json + + List up to 10 replication adapters + $ stackit registry replication-adapter list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit registry replication-adapter 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 replication-adapter](./stackit_registry_replication-adapter.md) - Provides functionality for Container Registry replication adapters + diff --git a/internal/cmd/registry/registry.go b/internal/cmd/registry/registry.go index e9d230aa0..e5340a7ac 100644 --- a/internal/cmd/registry/registry.go +++ b/internal/cmd/registry/registry.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/registry/artifactory" + replicationadapter "github.com/stackitcloud/stackit-cli/internal/cmd/registry/replication-adapter" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -23,4 +24,5 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(artifactory.NewCmd(params)) + cmd.AddCommand(replicationadapter.NewCmd(params)) } diff --git a/internal/cmd/registry/replication-adapter/list/list.go b/internal/cmd/registry/replication-adapter/list/list.go new file mode 100644 index 000000000..dfb72629a --- /dev/null +++ b/internal/cmd/registry/replication-adapter/list/list.go @@ -0,0 +1,146 @@ +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 replication adapters", + Long: "Lists all available adapters for replication to/from other registries.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all replication adapters`, + "$ stackit registry replication-adapter list", + ), + examples.NewExample( + `List all replication adapters in JSON format`, + "$ stackit registry replication-adapter list --output-format json", + ), + examples.NewExample( + `List up to 10 replication adapters`, + "$ stackit registry replication-adapter 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("list replication adapters: %w", err) + } + + var adapters []registry.ReplicationAdapter + if resp != nil { + adapters = resp.ReplicationAdapters + } + + if model.Limit != nil && len(adapters) > int(*model.Limit) { + adapters = adapters[:*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, adapters) + }, + } + + 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.ApiListReplicationAdaptersRequest { + return apiClient.DefaultAPI.ListReplicationAdapters(ctx, model.ProjectId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, adapters []registry.ReplicationAdapter) error { + return p.OutputResult(outputFormat, adapters, func() error { + if len(adapters) == 0 { + p.Outputf("No replication adapters found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("REGISTRY TYPE", "DISPLAY NAME", "CREDENTIAL TYPE") + + for _, adapter := range adapters { + table.AddRow( + adapter.RegistryType, + adapter.DisplayName, + adapter.CredentialType, + ) + } + + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/registry/replication-adapter/list/list_test.go b/internal/cmd/registry/replication-adapter/list/list_test.go new file mode 100644 index 000000000..b258cce4f --- /dev/null +++ b/internal/cmd/registry/replication-adapter/list/list_test.go @@ -0,0 +1,227 @@ +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.ApiListReplicationAdaptersRequest)) registry.ApiListReplicationAdaptersRequest { + request := testClient.DefaultAPI.ListReplicationAdapters(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.ApiListReplicationAdaptersRequest + }{ + { + 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 + adapters []registry.ReplicationAdapter + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty adapters", + args: args{ + projectLabel: "my-project", + adapters: []registry.ReplicationAdapter{}, + }, + wantErr: false, + }, + { + name: "single item", + args: args{ + projectLabel: "my-project", + adapters: []registry.ReplicationAdapter{ + { + RegistryType: "docker-hub", + DisplayName: "Docker Hub", + CredentialType: registry.REPLICATIONADAPTERCREDENTIALTYPE_BASIC, + }, + }, + }, + wantErr: false, + }, + { + name: "multiple items", + args: args{ + projectLabel: "my-project", + adapters: []registry.ReplicationAdapter{ + { + RegistryType: "docker-hub", + DisplayName: "Docker Hub", + CredentialType: registry.REPLICATIONADAPTERCREDENTIALTYPE_BASIC, + }, + { + RegistryType: "harbor", + DisplayName: "Harbor", + CredentialType: registry.REPLICATIONADAPTERCREDENTIALTYPE_BASIC, + }, + }, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := outputResult(params.Printer, "", tt.args.projectLabel, tt.args.adapters) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/registry/replication-adapter/replication_adapter.go b/internal/cmd/registry/replication-adapter/replication_adapter.go new file mode 100644 index 000000000..fe2853146 --- /dev/null +++ b/internal/cmd/registry/replication-adapter/replication_adapter.go @@ -0,0 +1,27 @@ +package replicationadapter + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/registry/replication-adapter/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "replication-adapter", + Aliases: []string{"replication-adapters"}, + Short: "Provides functionality for Container Registry replication adapters", + Long: "Provides functionality for Container Registry replication adapters.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) +} From 334d287314143dba430bc78cca642f7c8251865b Mon Sep 17 00:00:00 2001 From: Alexandre Catalan Date: Tue, 3 Nov 2026 21:26:49 +0100 Subject: [PATCH 20/20] test(registry): add comprehensive edge case coverage across commands - Add TestFetchArtifactories unit tests covering pagination, limit, and error handling - Add test cases for proxy access secret without key and empty flags in create - Add test case for proxy without description in describe output - Add test case for nil model in create and update outputResult - Add JSON output format test case in replication-adapter list Relates to STACKITCRE-382 --- .../artifactory/create/create_test.go | 37 ++++++ .../artifactory/describe/describe_test.go | 19 ++++ .../registry/artifactory/list/list_test.go | 106 ++++++++++++++++++ .../artifactory/update/update_test.go | 9 ++ .../replication-adapter/list/list_test.go | 19 +++- 5 files changed, 189 insertions(+), 1 deletion(-) diff --git a/internal/cmd/registry/artifactory/create/create_test.go b/internal/cmd/registry/artifactory/create/create_test.go index 05a48d254..48b9c7f9e 100644 --- a/internal/cmd/registry/artifactory/create/create_test.go +++ b/internal/cmd/registry/artifactory/create/create_test.go @@ -174,6 +174,20 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + description: "proxy access secret without key", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[proxyUrlFlag] = testProxyUrl + flagValues[proxyRegistryTypeFlag] = testProxyRegistryType + flagValues[proxyAccessSecretFlag] = "@" + testSecretFile + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, { description: "proxy description without proxy url", flagValues: fixtureFlagValues(func(flagValues map[string]string) { @@ -235,6 +249,21 @@ func TestBuildRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "with proxy minimal", + model: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = utils.Ptr(testProxyUrl) + model.ProxyRegistryType = utils.Ptr(testProxyRegistryType) + }), + expectedRequest: testClient.DefaultAPI.CreateArtifactory(testCtx, testProjectId, testRegion). + CreateArtifactoryPayload(registry.CreateArtifactoryPayload{ + Name: testName, + Proxy: ®istry.CreateProxyRequest{ + Url: testProxyUrl, + RegistryType: testProxyRegistryType, + }, + }), + }, { description: "with proxy full", model: fixtureInputModel(func(model *inputModel) { @@ -287,6 +316,14 @@ func TestOutputResult(t *testing.T) { args args wantErr bool }{ + { + name: "nil model", + args: args{ + model: nil, + resp: ®istry.Artifactory{Id: "xxx"}, + }, + wantErr: true, + }, { name: "empty response", args: args{ diff --git a/internal/cmd/registry/artifactory/describe/describe_test.go b/internal/cmd/registry/artifactory/describe/describe_test.go index 53513da8b..83ce8ad38 100644 --- a/internal/cmd/registry/artifactory/describe/describe_test.go +++ b/internal/cmd/registry/artifactory/describe/describe_test.go @@ -212,6 +212,25 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "with proxy without description", + 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: nil, + RegistryType: "docker-hub", + }, + }, + }, + wantErr: false, + }, { name: "nil artifactory", args: args{ diff --git a/internal/cmd/registry/artifactory/list/list_test.go b/internal/cmd/registry/artifactory/list/list_test.go index 3c8b12f7e..58785d6ad 100644 --- a/internal/cmd/registry/artifactory/list/list_test.go +++ b/internal/cmd/registry/artifactory/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "context" + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -216,3 +217,108 @@ func TestOutputResult(t *testing.T) { }) } } + +func TestFetchArtifactories(t *testing.T) { + tests := []struct { + description string + model *inputModel + mockResponses []*registry.ArtifactoryList + mockErrors []error + expectedItems int + wantErr bool + }{ + { + description: "single page, no limit", + model: fixtureInputModel(), + mockResponses: []*registry.ArtifactoryList{ + { + Artifactories: []registry.Artifactory{ + {Id: "1", Name: "reg-1"}, + {Id: "2", Name: "reg-2"}, + }, + }, + }, + expectedItems: 2, + wantErr: false, + }, + { + description: "multi page with pagination", + model: fixtureInputModel(), + mockResponses: []*registry.ArtifactoryList{ + { + Artifactories: []registry.Artifactory{ + {Id: "1", Name: "reg-1"}, + }, + NextPageToken: utils.Ptr("token-1"), + }, + { + Artifactories: []registry.Artifactory{ + {Id: "2", Name: "reg-2"}, + }, + }, + }, + expectedItems: 2, + wantErr: false, + }, + { + description: "with limit smaller than available", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(1)) + }), + mockResponses: []*registry.ArtifactoryList{ + { + Artifactories: []registry.Artifactory{ + {Id: "1", Name: "reg-1"}, + {Id: "2", Name: "reg-2"}, + }, + }, + }, + expectedItems: 1, + wantErr: false, + }, + { + description: "api call fails", + model: fixtureInputModel(), + mockErrors: []error{ + fmt.Errorf("server error"), + }, + wantErr: true, + }, + { + description: "nil response", + model: fixtureInputModel(), + mockResponses: []*registry.ArtifactoryList{ + nil, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + callIndex := 0 + mock := registry.DefaultAPIServiceMock{ + ListArtifactoriesExecuteMock: utils.Ptr(func(_ registry.ApiListArtifactoriesRequest) (*registry.ArtifactoryList, error) { + idx := callIndex + callIndex++ + if idx < len(tt.mockErrors) && tt.mockErrors[idx] != nil { + return nil, tt.mockErrors[idx] + } + if idx < len(tt.mockResponses) { + return tt.mockResponses[idx], nil + } + return ®istry.ArtifactoryList{}, nil + }), + } + client := ®istry.APIClient{DefaultAPI: mock} + + got, err := fetchArtifactories(testCtx, tt.model, client) + if (err != nil) != tt.wantErr { + t.Fatalf("fetchArtifactories() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && len(got) != tt.expectedItems { + t.Errorf("fetchArtifactories() returned %d items, want %d", len(got), tt.expectedItems) + } + }) + } +} diff --git a/internal/cmd/registry/artifactory/update/update_test.go b/internal/cmd/registry/artifactory/update/update_test.go index d5add9dca..995304010 100644 --- a/internal/cmd/registry/artifactory/update/update_test.go +++ b/internal/cmd/registry/artifactory/update/update_test.go @@ -315,6 +315,15 @@ func TestOutputResult(t *testing.T) { args args wantErr bool }{ + { + name: "nil model", + args: args{ + model: nil, + artifactoryLabel: "my-artifactory", + resp: ®istry.Artifactory{Id: testArtifactoryId}, + }, + wantErr: true, + }, { name: "updated successfully sync", args: args{ diff --git a/internal/cmd/registry/replication-adapter/list/list_test.go b/internal/cmd/registry/replication-adapter/list/list_test.go index b258cce4f..8e3da6ee7 100644 --- a/internal/cmd/registry/replication-adapter/list/list_test.go +++ b/internal/cmd/registry/replication-adapter/list/list_test.go @@ -10,6 +10,7 @@ import ( 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/print" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -163,6 +164,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { + outputFormat string projectLabel string adapters []registry.ReplicationAdapter } @@ -212,13 +214,28 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "json output format", + args: args{ + outputFormat: print.JSONOutputFormat, + projectLabel: "my-project", + adapters: []registry.ReplicationAdapter{ + { + RegistryType: "docker-hub", + DisplayName: "Docker Hub", + CredentialType: registry.REPLICATIONADAPTERCREDENTIALTYPE_BASIC, + }, + }, + }, + wantErr: false, + }, } params := testparams.NewTestParams() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := outputResult(params.Printer, "", tt.args.projectLabel, tt.args.adapters) + err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.adapters) if (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) }