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_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/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..2707c3efc --- /dev/null +++ b/docs/stackit_registry_artifactory_create.md @@ -0,0 +1,52 @@ +## 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 + + 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 + --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-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 + +``` + -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..71a370967 --- /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 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 +``` + +### 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/go.mod b/go.mod index 0a5e415cb..a2b22acc8 100644 --- a/go.mod +++ b/go.mod @@ -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.27.0 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.19.1 diff --git a/go.sum b/go.sum index 3dd530bfb..2e887f071 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.27.0 h1:lZl9X/wl0tF+HufZiurUkYJmk4u6/Vz8/FkdgT3wA10= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.27.0/go.mod h1:IsId3wbkp5oaO0JEaHZpZnbVW6KFHlAptwB+1iG3v3w= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 h1:EVeeGzvyiCN4VbInHIWXZtL8hPsVzZIdnfXjgyZzUf0= 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 }), }, { 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..776a30690 --- /dev/null +++ b/internal/cmd/registry/artifactory/create/create.go @@ -0,0 +1,205 @@ +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" + + 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 + ProxyUrl *string + ProxyRegistryType *string + ProxyDescription *string + ProxyAccessKey *string + ProxyAccessSecret *string `json:"-"` +} + +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", + ), + 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() + 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 { + 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) + } + } + + return outputResult(params.Printer, model, projectLabel, artifactoryId, resp) + }, + } + configureFlags(cmd, params) + return cmd +} + +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") + + secret := flags.SecretFlag(proxyAccessSecretFlag, params) + cmd.Flags().Var(secret, proxyAccessSecretFlag, secret.Usage()) + + 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) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + 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.SecretFlagToStringPointer(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), + ProxyUrl: proxyUrl, + ProxyRegistryType: proxyRegistryType, + ProxyDescription: proxyDesc, + ProxyAccessKey: proxyKey, + ProxyAccessSecret: proxySecret, + } + + 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) + 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 { + 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..fef8e7606 --- /dev/null +++ b/internal/cmd/registry/artifactory/create/create_test.go @@ -0,0 +1,291 @@ +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" + "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() + 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 { + 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: "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) { + 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(), + }, + { + 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 { + 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..52f0ba375 --- /dev/null +++ b/internal/cmd/registry/artifactory/describe/describe.go @@ -0,0 +1,127 @@ +package describe + +import ( + "context" + "fmt" + "time" + + "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.Format(time.DateTime)) + 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..87829e49b --- /dev/null +++ b/internal/cmd/registry/artifactory/list/list.go @@ -0,0 +1,182 @@ +package list + +import ( + "context" + "fmt" + "math" + + "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" + maxPageSize = 100 +) + +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 + artifactories, err := fetchArtifactories(ctx, model, apiClient) + 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 + } + + 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, 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 { + 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..3c8b12f7e --- /dev/null +++ b/internal/cmd/registry/artifactory/list/list_test.go @@ -0,0 +1,218 @@ +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).PageSize(maxPageSize) + 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 + 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, tt.pageToken, tt.pageSize) + + 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..8db95bd2e --- /dev/null +++ b/internal/cmd/registry/artifactory/update/update.go @@ -0,0 +1,180 @@ +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 `json:"-"` +} + +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 { + 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) + } + } + + return outputResult(params.Printer, model, artifactoryLabel, resp) + }, + } + configureFlags(cmd, params) + return cmd +} + +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") + + 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) { + 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.SecretFlagToStringPointer(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..f0d16e7cc --- /dev/null +++ b/internal/cmd/registry/artifactory/update/update_test.go @@ -0,0 +1,283 @@ +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" + testProxyDescription = "docker hub proxy" + testProxyAccessKey = "my-key" + testProxyAccessSecret = "my-secret" +) + +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: "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}, + 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(), + }, + { + 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 { + 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..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" @@ -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. 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..905569586 --- /dev/null +++ b/internal/pkg/services/registry/utils/utils.go @@ -0,0 +1,19 @@ +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) + } + 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) + } + }) + } +}