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 e53de9c0e..4f50c7247 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 --scf-custom-endpoint string STACKIT Cloud Foundry API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index b2ed52a05..90549a6f5 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 --scf-custom-endpoint STACKIT Cloud Foundry 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..2107389a2 --- /dev/null +++ b/docs/stackit_registry.md @@ -0,0 +1,35 @@ +## 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 +* [stackit registry replication-adapter](./stackit_registry_replication-adapter.md) - Provides functionality for Container Registry replication adapters + 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..3bbd76561 --- /dev/null +++ b/docs/stackit_registry_artifactory_create.md @@ -0,0 +1,55 @@ +## 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, 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 + +``` + -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 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 +``` + +### 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..bf244f340 --- /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 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 +``` + +### 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_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/go.mod b/go.mod index 63c86c5b6..af2f00a37 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.16.0 + github.com/stackitcloud/stackit-sdk-go/services/registry v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.28.0 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 github.com/stackitcloud/stackit-sdk-go/services/scf v0.11.0 diff --git a/go.sum b/go.sum index 98878686e..6a7a17f08 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.28.0 h1:rZB1f9g5M/2mWKXsJIfMvGcFpFWkDQFjG28V/d1ezWw= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.28.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 477695495..b05f841ed 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -58,6 +58,7 @@ const ( scfCustomEndpointFlag = "scf-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + registryCustomEndpointFlag = "registry-custom-endpoint" ) type inputModel struct { @@ -182,6 +183,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(scfCustomEndpointFlag, "", "STACKIT Cloud Foundry 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) @@ -260,6 +262,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 57600ecea..e4621aa3a 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -61,6 +61,7 @@ const ( logsCustomEndpointFlag = "logs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" + registryCustomEndpointFlag = "registry-custom-endpoint" ) var ( @@ -115,6 +116,7 @@ type inputModel struct { LogsCustomEndpoint bool CDNCustomEndpoint bool VpnCustomEndpoint bool + RegistryCustomEndpoint bool } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -271,6 +273,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 { @@ -330,6 +335,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 { @@ -380,6 +386,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 55008562d..1c431d6d7 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -53,6 +53,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) @@ -104,6 +105,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { LogsCustomEndpoint: true, CDNCustomEndpoint: true, VpnCustomEndpoint: true, + RegistryCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -171,6 +173,7 @@ func TestParseInput(t *testing.T) { model.LogsCustomEndpoint = false model.CDNCustomEndpoint = false model.VpnCustomEndpoint = false + model.RegistryCustomEndpoint = false }), }, { @@ -293,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/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..148378456 --- /dev/null +++ b/internal/cmd/registry/artifactory/create/create.go @@ -0,0 +1,212 @@ +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, 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 { + 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, 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) { + 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..48b9c7f9e --- /dev/null +++ b/internal/cmd/registry/artifactory/create/create_test.go @@ -0,0 +1,370 @@ +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" +) + +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" + testSecretFile = "secret.txt" +) + +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 { + stdin string + 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] = "@" + testSecretFile + }), + 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 - 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) { + 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 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) { + 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, 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) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest registry.ApiCreateArtifactoryRequest + }{ + { + description: "base", + 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) { + 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: "nil model", + args: args{ + model: nil, + resp: ®istry.Artifactory{Id: "xxx"}, + }, + wantErr: true, + }, + { + 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..edfbfe208 --- /dev/null +++ b/internal/cmd/registry/artifactory/delete/delete_test.go @@ -0,0 +1,168 @@ +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, + }, + { + 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) + }) + } +} + +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..83ce8ad38 --- /dev/null +++ b/internal/cmd/registry/artifactory/describe/describe_test.go @@ -0,0 +1,250 @@ +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, + }, + { + 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) + }) + } +} + +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: "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{ + 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..58785d6ad --- /dev/null +++ b/internal/cmd/registry/artifactory/list/list_test.go @@ -0,0 +1,324 @@ +package list + +import ( + "context" + "fmt" + "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) + } + }) + } +} + +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.go b/internal/cmd/registry/artifactory/update/update.go new file mode 100644 index 000000000..5ff5ebf94 --- /dev/null +++ b/internal/cmd/registry/artifactory/update/update.go @@ -0,0 +1,183 @@ +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, 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{} + } + + 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..995304010 --- /dev/null +++ b/internal/cmd/registry/artifactory/update/update_test.go @@ -0,0 +1,360 @@ +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" +) + +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" + testSecretFile = "secret.txt" +) + +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 { + stdin string + 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] = "@" + testSecretFile + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProxyUrl = nil + model.ProxyAccessKey = utils.Ptr(testProxyAccessKey) + 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}, + 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] = "@" + testSecretFile + }), + 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, + }, + { + 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, 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) + }) + } +} + +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: "nil model", + args: args{ + model: nil, + artifactoryLabel: "my-artifactory", + resp: ®istry.Artifactory{Id: testArtifactoryId}, + }, + wantErr: true, + }, + { + 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..e5340a7ac --- /dev/null +++ b/internal/cmd/registry/registry.go @@ -0,0 +1,28 @@ +package registry + +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" +) + +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)) + 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..8e3da6ee7 --- /dev/null +++ b/internal/cmd/registry/replication-adapter/list/list_test.go @@ -0,0 +1,244 @@ +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/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" +) + +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 { + outputFormat string + 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, + }, + { + 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.outputFormat, 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)) +} 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 56c53899b..f1095de32 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -60,6 +60,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" @@ -133,6 +134,7 @@ var ConfigKeys = []string{ SfsCustomEndpointKey, TokenCustomEndpointKey, ValkeyCustomEndpointKey, + RegistryCustomEndpointKey, VPNCustomEndpointKey, } @@ -230,6 +232,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) + } + }) + } +}