diff --git a/docs/stackit.md b/docs/stackit.md index 91fa48bed..c37b8e90e 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -31,6 +31,7 @@ stackit [flags] * [stackit auth](./stackit_auth.md) - Authenticates the STACKIT CLI * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands * [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options +* [stackit containerregistry](./stackit_containerregistry.md) - Provides functionality for Container Registry * [stackit curl](./stackit_curl.md) - Executes an authenticated HTTP request to an endpoint * [stackit dns](./stackit_dns.md) - Provides functionality for DNS * [stackit git](./stackit_git.md) - Provides functionality for STACKIT Git diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index e53de9c0e..1d73808f4 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -34,6 +34,7 @@ stackit config set [flags] --authorization-custom-endpoint string Authorization API base URL, used in calls to this API --automation-custom-endpoint string Automation API base URL, used in calls to this API --cdn-custom-endpoint string CDN API base URL, used in calls to this API + --containerregistry-custom-endpoint string Container Registry API base URL, used in calls to this API --dns-custom-endpoint string DNS API base URL, used in calls to this API --edge-custom-endpoint string Edge API base URL, used in calls to this API -h, --help Help for "stackit config set" diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index b2ed52a05..be1c619cb 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -33,6 +33,7 @@ stackit config unset [flags] --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL --automation-custom-endpoint Automation API base URL. If unset, uses the default base URL --cdn-custom-endpoint Custom CDN endpoint URL. If unset, uses the default base URL + --containerregistry-custom-endpoint Container Registry API base URL. If unset, uses the default base URL --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL --edge-custom-endpoint Edge API base URL. If unset, uses the default base URL -h, --help Help for "stackit config unset" diff --git a/docs/stackit_containerregistry.md b/docs/stackit_containerregistry.md new file mode 100644 index 000000000..17a6cf5d2 --- /dev/null +++ b/docs/stackit_containerregistry.md @@ -0,0 +1,35 @@ +## stackit containerregistry + +Provides functionality for Container Registry + +### Synopsis + +Provides functionality for Container Registry. + +``` +stackit containerregistry [flags] +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry" +``` + +### 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 containerregistry artifactory](./stackit_containerregistry_artifactory.md) - Provides functionality for Container Registry artifactories +* [stackit containerregistry replication-adapter](./stackit_containerregistry_replication-adapter.md) - Provides functionality for Container Registry replication adapters + diff --git a/docs/stackit_containerregistry_artifactory.md b/docs/stackit_containerregistry_artifactory.md new file mode 100644 index 000000000..249dddfd3 --- /dev/null +++ b/docs/stackit_containerregistry_artifactory.md @@ -0,0 +1,38 @@ +## stackit containerregistry artifactory + +Provides functionality for Container Registry artifactories + +### Synopsis + +Provides functionality for Container Registry artifactories. + +``` +stackit containerregistry artifactory [flags] +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry](./stackit_containerregistry.md) - Provides functionality for Container Registry +* [stackit containerregistry artifactory create](./stackit_containerregistry_artifactory_create.md) - Creates a Container Registry artifactory +* [stackit containerregistry artifactory delete](./stackit_containerregistry_artifactory_delete.md) - Deletes a Container Registry artifactory +* [stackit containerregistry artifactory describe](./stackit_containerregistry_artifactory_describe.md) - Shows details of a Container Registry artifactory +* [stackit containerregistry artifactory list](./stackit_containerregistry_artifactory_list.md) - Lists all Container Registry artifactories +* [stackit containerregistry artifactory update](./stackit_containerregistry_artifactory_update.md) - Updates a Container Registry artifactory + diff --git a/docs/stackit_containerregistry_artifactory_create.md b/docs/stackit_containerregistry_artifactory_create.md new file mode 100644 index 000000000..21e5b6797 --- /dev/null +++ b/docs/stackit_containerregistry_artifactory_create.md @@ -0,0 +1,55 @@ +## stackit containerregistry artifactory create + +Creates a Container Registry artifactory + +### Synopsis + +Creates a Container Registry artifactory. + +``` +stackit containerregistry artifactory create [flags] +``` + +### Examples + +``` + Create an artifactory with name "my-registry" + $ stackit containerregistry artifactory create --name my-registry + + Create a proxy-cache artifactory for Docker Hub + $ stackit containerregistry 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 containerregistry 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 containerregistry 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 containerregistry 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 containerregistry artifactory](./stackit_containerregistry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_containerregistry_artifactory_delete.md b/docs/stackit_containerregistry_artifactory_delete.md new file mode 100644 index 000000000..c68ded4c5 --- /dev/null +++ b/docs/stackit_containerregistry_artifactory_delete.md @@ -0,0 +1,40 @@ +## stackit containerregistry artifactory delete + +Deletes a Container Registry artifactory + +### Synopsis + +Deletes a Container Registry artifactory. + +``` +stackit containerregistry artifactory delete ARTIFACTORY_ID [flags] +``` + +### Examples + +``` + Delete an artifactory with ID "xxx" + $ stackit containerregistry artifactory delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry artifactory](./stackit_containerregistry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_containerregistry_artifactory_describe.md b/docs/stackit_containerregistry_artifactory_describe.md new file mode 100644 index 000000000..1ceeda085 --- /dev/null +++ b/docs/stackit_containerregistry_artifactory_describe.md @@ -0,0 +1,43 @@ +## stackit containerregistry artifactory describe + +Shows details of a Container Registry artifactory + +### Synopsis + +Shows details of a Container Registry artifactory. + +``` +stackit containerregistry artifactory describe ARTIFACTORY_ID [flags] +``` + +### Examples + +``` + Get details of an artifactory with ID "xxx" + $ stackit containerregistry artifactory describe xxx + + Get details of an artifactory with ID "xxx" in JSON format + $ stackit containerregistry artifactory describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry artifactory](./stackit_containerregistry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_containerregistry_artifactory_list.md b/docs/stackit_containerregistry_artifactory_list.md new file mode 100644 index 000000000..ca4a1b806 --- /dev/null +++ b/docs/stackit_containerregistry_artifactory_list.md @@ -0,0 +1,47 @@ +## stackit containerregistry artifactory list + +Lists all Container Registry artifactories + +### Synopsis + +Lists all Container Registry artifactories. + +``` +stackit containerregistry artifactory list [flags] +``` + +### Examples + +``` + List all artifactories + $ stackit containerregistry artifactory list + + List all artifactories in JSON format + $ stackit containerregistry artifactory list --output-format json + + List up to 10 artifactories + $ stackit containerregistry artifactory list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry artifactory](./stackit_containerregistry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_containerregistry_artifactory_update.md b/docs/stackit_containerregistry_artifactory_update.md new file mode 100644 index 000000000..f24dedeea --- /dev/null +++ b/docs/stackit_containerregistry_artifactory_update.md @@ -0,0 +1,44 @@ +## stackit containerregistry artifactory update + +Updates a Container Registry artifactory + +### Synopsis + +Updates a Container Registry artifactory. + +``` +stackit containerregistry artifactory update ARTIFACTORY_ID [flags] +``` + +### Examples + +``` + Update the proxy configuration for an artifactory with ID "xxx" + $ stackit containerregistry artifactory update xxx --proxy-url https://registry-1.docker.io +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry artifactory](./stackit_containerregistry_artifactory.md) - Provides functionality for Container Registry artifactories + diff --git a/docs/stackit_containerregistry_replication-adapter.md b/docs/stackit_containerregistry_replication-adapter.md new file mode 100644 index 000000000..57c70ec73 --- /dev/null +++ b/docs/stackit_containerregistry_replication-adapter.md @@ -0,0 +1,34 @@ +## stackit containerregistry replication-adapter + +Provides functionality for Container Registry replication adapters + +### Synopsis + +Provides functionality for Container Registry replication adapters. + +``` +stackit containerregistry replication-adapter [flags] +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry](./stackit_containerregistry.md) - Provides functionality for Container Registry +* [stackit containerregistry replication-adapter list](./stackit_containerregistry_replication-adapter_list.md) - Lists all Container Registry replication adapters + diff --git a/docs/stackit_containerregistry_replication-adapter_list.md b/docs/stackit_containerregistry_replication-adapter_list.md new file mode 100644 index 000000000..e5cf15bab --- /dev/null +++ b/docs/stackit_containerregistry_replication-adapter_list.md @@ -0,0 +1,47 @@ +## stackit containerregistry replication-adapter list + +Lists all Container Registry replication adapters + +### Synopsis + +Lists all available adapters for replication to/from other registries. + +``` +stackit containerregistry replication-adapter list [flags] +``` + +### Examples + +``` + List all replication adapters + $ stackit containerregistry replication-adapter list + + List all replication adapters in JSON format + $ stackit containerregistry replication-adapter list --output-format json + + List up to 10 replication adapters + $ stackit containerregistry replication-adapter list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit containerregistry 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 containerregistry replication-adapter](./stackit_containerregistry_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..0a6ae6f61 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": "", + "containerregistry_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..335b5f073 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" + containerRegistryCustomEndpointFlag = "containerregistry-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(containerRegistryCustomEndpointFlag, "", "Container 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.ContainerRegistryCustomEndpointKey, cmd.Flags().Lookup(containerRegistryCustomEndpointFlag)) + 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..486253a5e 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" + containerRegistryCustomEndpointFlag = "containerregistry-custom-endpoint" ) var ( @@ -115,6 +116,7 @@ type inputModel struct { LogsCustomEndpoint bool CDNCustomEndpoint bool VpnCustomEndpoint bool + ContainerRegistryCustomEndpoint 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.ContainerRegistryCustomEndpoint { + viper.Set(config.ContainerRegistryCustomEndpointKey, "") + } 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(containerRegistryCustomEndpointFlag, false, "Container 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), + ContainerRegistryCustomEndpoint: flags.FlagToBoolValue(p, cmd, containerRegistryCustomEndpointFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 55008562d..9f865b2c0 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -22,37 +22,38 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool identityProviderCustomClientIdFlag: true, allowedUrlDomainFlag: true, - authorizationCustomEndpointFlag: true, - automationCustomEndpointFlag: true, - albWafCustomEndpointFlag: true, - dnsCustomEndpointFlag: true, - edgeCustomEndpointFlag: true, - loadBalancerCustomEndpointFlag: true, - logMeCustomEndpointFlag: true, - mariaDBCustomEndpointFlag: true, - objectStorageCustomEndpointFlag: true, - observabilityCustomEndpointFlag: true, - openSearchCustomEndpointFlag: true, - rabbitMQCustomEndpointFlag: true, - redisCustomEndpointFlag: true, - resourceManagerCustomEndpointFlag: true, - secretsManagerCustomEndpointFlag: true, - kmsCustomEndpointFlag: true, - serviceAccountCustomEndpointFlag: true, - serverBackupCustomEndpointFlag: true, - serverOsUpdateCustomEndpointFlag: true, - runCommandCustomEndpointFlag: true, - scfCustomEndpointFlag: true, - sfsCustomEndpointFlag: true, - skeCustomEndpointFlag: true, - sqlServerFlexCustomEndpointFlag: true, - iaasAlphaCustomEndpointFlag: true, - iaasCustomEndpointFlag: true, - tokenCustomEndpointFlag: true, - intakeCustomEndpointFlag: true, - logsCustomEndpointFlag: true, - cdnCustomEndpointFlag: true, - vpnCustomEndpointFlag: true, + authorizationCustomEndpointFlag: true, + automationCustomEndpointFlag: true, + albWafCustomEndpointFlag: true, + dnsCustomEndpointFlag: true, + edgeCustomEndpointFlag: true, + loadBalancerCustomEndpointFlag: true, + logMeCustomEndpointFlag: true, + mariaDBCustomEndpointFlag: true, + objectStorageCustomEndpointFlag: true, + observabilityCustomEndpointFlag: true, + openSearchCustomEndpointFlag: true, + rabbitMQCustomEndpointFlag: true, + redisCustomEndpointFlag: true, + resourceManagerCustomEndpointFlag: true, + secretsManagerCustomEndpointFlag: true, + kmsCustomEndpointFlag: true, + serviceAccountCustomEndpointFlag: true, + serverBackupCustomEndpointFlag: true, + serverOsUpdateCustomEndpointFlag: true, + runCommandCustomEndpointFlag: true, + scfCustomEndpointFlag: true, + sfsCustomEndpointFlag: true, + skeCustomEndpointFlag: true, + sqlServerFlexCustomEndpointFlag: true, + iaasAlphaCustomEndpointFlag: true, + iaasCustomEndpointFlag: true, + tokenCustomEndpointFlag: true, + intakeCustomEndpointFlag: true, + logsCustomEndpointFlag: true, + cdnCustomEndpointFlag: true, + vpnCustomEndpointFlag: true, + containerRegistryCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -73,37 +74,38 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { IdentityProviderCustomClientID: true, AllowedUrlDomain: true, - AuthorizationCustomEndpoint: true, - AutomationCustomEndpoint: true, - AlbWafCustomEndpoint: true, - DNSCustomEndpoint: true, - EdgeCustomEndpoint: true, - LoadBalancerCustomEndpoint: true, - LogMeCustomEndpoint: true, - MariaDBCustomEndpoint: true, - ObjectStorageCustomEndpoint: true, - ObservabilityCustomEndpoint: true, - OpenSearchCustomEndpoint: true, - RabbitMQCustomEndpoint: true, - RedisCustomEndpoint: true, - ResourceManagerCustomEndpoint: true, - SecretsManagerCustomEndpoint: true, - KMSCustomEndpoint: true, - ServiceAccountCustomEndpoint: true, - ServerBackupCustomEndpoint: true, - ServerOsUpdateCustomEndpoint: true, - RunCommandCustomEndpoint: true, - ScfCustomEndpoint: true, - SfsCustomEndpoint: true, - SKECustomEndpoint: true, - SQLServerFlexCustomEndpoint: true, - IaaSAlphaCustomEndpoint: true, - IaaSCustomEndpoint: true, - TokenCustomEndpoint: true, - IntakeCustomEndpoint: true, - LogsCustomEndpoint: true, - CDNCustomEndpoint: true, - VpnCustomEndpoint: true, + AuthorizationCustomEndpoint: true, + AutomationCustomEndpoint: true, + AlbWafCustomEndpoint: true, + DNSCustomEndpoint: true, + EdgeCustomEndpoint: true, + LoadBalancerCustomEndpoint: true, + LogMeCustomEndpoint: true, + MariaDBCustomEndpoint: true, + ObjectStorageCustomEndpoint: true, + ObservabilityCustomEndpoint: true, + OpenSearchCustomEndpoint: true, + RabbitMQCustomEndpoint: true, + RedisCustomEndpoint: true, + ResourceManagerCustomEndpoint: true, + SecretsManagerCustomEndpoint: true, + KMSCustomEndpoint: true, + ServiceAccountCustomEndpoint: true, + ServerBackupCustomEndpoint: true, + ServerOsUpdateCustomEndpoint: true, + RunCommandCustomEndpoint: true, + ScfCustomEndpoint: true, + SfsCustomEndpoint: true, + SKECustomEndpoint: true, + SQLServerFlexCustomEndpoint: true, + IaaSAlphaCustomEndpoint: true, + IaaSCustomEndpoint: true, + TokenCustomEndpoint: true, + IntakeCustomEndpoint: true, + LogsCustomEndpoint: true, + CDNCustomEndpoint: true, + VpnCustomEndpoint: true, + ContainerRegistryCustomEndpoint: 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.ContainerRegistryCustomEndpoint = false }), }, { @@ -293,6 +296,16 @@ func TestParseInput(t *testing.T) { model.KMSCustomEndpoint = false }), }, + { + description: "container registry custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[containerRegistryCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ContainerRegistryCustomEndpoint = false + }), + }, { description: "service account custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/cmd/containerregistry/artifactory/artifactory.go b/internal/cmd/containerregistry/artifactory/artifactory.go new file mode 100644 index 000000000..325b38b2b --- /dev/null +++ b/internal/cmd/containerregistry/artifactory/artifactory.go @@ -0,0 +1,34 @@ +package artifactory + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/artifactory/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/artifactory/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/artifactory/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/artifactory/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/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/containerregistry/artifactory/create/create.go b/internal/cmd/containerregistry/artifactory/create/create.go new file mode 100644 index 000000000..80ac98161 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/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 containerregistry artifactory create --name my-registry", + ), + examples.NewExample( + `Create a proxy-cache artifactory for Docker Hub`, + "$ stackit containerregistry 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 containerregistry 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 containerregistry 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/containerregistry/artifactory/create/create_test.go b/internal/cmd/containerregistry/artifactory/create/create_test.go new file mode 100644 index 000000000..48b9c7f9e --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/artifactory/delete/delete.go b/internal/cmd/containerregistry/artifactory/delete/delete.go new file mode 100644 index 000000000..582712cf8 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/client" + containerRegistryUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/containerregistry/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 containerregistry 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 := containerRegistryUtils.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/containerregistry/artifactory/delete/delete_test.go b/internal/cmd/containerregistry/artifactory/delete/delete_test.go new file mode 100644 index 000000000..edfbfe208 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/artifactory/describe/describe.go b/internal/cmd/containerregistry/artifactory/describe/describe.go new file mode 100644 index 000000000..27d69f08f --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/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 containerregistry artifactory describe xxx"), + examples.NewExample( + `Get details of an artifactory with ID "xxx" in JSON format`, + "$ stackit containerregistry 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/containerregistry/artifactory/describe/describe_test.go b/internal/cmd/containerregistry/artifactory/describe/describe_test.go new file mode 100644 index 000000000..83ce8ad38 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/artifactory/list/list.go b/internal/cmd/containerregistry/artifactory/list/list.go new file mode 100644 index 000000000..f3e85b81f --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/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 containerregistry artifactory list"), + examples.NewExample( + `List all artifactories in JSON format`, + "$ stackit containerregistry artifactory list --output-format json"), + examples.NewExample( + `List up to 10 artifactories`, + "$ stackit containerregistry 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/containerregistry/artifactory/list/list_test.go b/internal/cmd/containerregistry/artifactory/list/list_test.go new file mode 100644 index 000000000..58785d6ad --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/artifactory/update/update.go b/internal/cmd/containerregistry/artifactory/update/update.go new file mode 100644 index 000000000..b57f9c761 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/client" + containerRegistryUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/containerregistry/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 containerregistry 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 := containerRegistryUtils.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/containerregistry/artifactory/update/update_test.go b/internal/cmd/containerregistry/artifactory/update/update_test.go new file mode 100644 index 000000000..995304010 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/containerregistry.go b/internal/cmd/containerregistry/containerregistry.go new file mode 100644 index 000000000..fc38d3414 --- /dev/null +++ b/internal/cmd/containerregistry/containerregistry.go @@ -0,0 +1,28 @@ +package containerregistry + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/artifactory" + replicationadapter "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/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: "containerregistry", + 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/containerregistry/replication-adapter/list/list.go b/internal/cmd/containerregistry/replication-adapter/list/list.go new file mode 100644 index 000000000..75db3587c --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/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 containerregistry replication-adapter list", + ), + examples.NewExample( + `List all replication adapters in JSON format`, + "$ stackit containerregistry replication-adapter list --output-format json", + ), + examples.NewExample( + `List up to 10 replication adapters`, + "$ stackit containerregistry 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/containerregistry/replication-adapter/list/list_test.go b/internal/cmd/containerregistry/replication-adapter/list/list_test.go new file mode 100644 index 000000000..8e3da6ee7 --- /dev/null +++ b/internal/cmd/containerregistry/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/containerregistry/replication-adapter/replication_adapter.go b/internal/cmd/containerregistry/replication-adapter/replication_adapter.go new file mode 100644 index 000000000..14d20bbef --- /dev/null +++ b/internal/cmd/containerregistry/replication-adapter/replication_adapter.go @@ -0,0 +1,27 @@ +package replicationadapter + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry/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..ab3246ca7 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/auth" "github.com/stackitcloud/stackit-cli/internal/cmd/beta" configCmd "github.com/stackitcloud/stackit-cli/internal/cmd/config" + "github.com/stackitcloud/stackit-cli/internal/cmd/containerregistry" "github.com/stackitcloud/stackit-cli/internal/cmd/curl" "github.com/stackitcloud/stackit-cli/internal/cmd/dns" "github.com/stackitcloud/stackit-cli/internal/cmd/git" @@ -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(containerregistry.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..9ea190586 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" + ContainerRegistryCustomEndpointKey = "containerregistry_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" ProjectNameKey = "project_name" @@ -133,6 +134,7 @@ var ConfigKeys = []string{ SfsCustomEndpointKey, TokenCustomEndpointKey, ValkeyCustomEndpointKey, + ContainerRegistryCustomEndpointKey, VPNCustomEndpointKey, } @@ -230,6 +232,7 @@ func setConfigDefaults() { viper.SetDefault(LogsCustomEndpointKey, "") viper.SetDefault(CDNCustomEndpointKey, "") viper.SetDefault(ValkeyCustomEndpointKey, "") + viper.SetDefault(ContainerRegistryCustomEndpointKey, "") viper.SetDefault(VPNCustomEndpointKey, "") } diff --git a/internal/pkg/services/containerregistry/client/client.go b/internal/pkg/services/containerregistry/client/client.go new file mode 100644 index 000000000..cee46f80f --- /dev/null +++ b/internal/pkg/services/containerregistry/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.ContainerRegistryCustomEndpointKey), false, genericclient.CreateApiClient[*registry.APIClient](registry.NewAPIClient)) +} diff --git a/internal/pkg/services/containerregistry/utils/utils.go b/internal/pkg/services/containerregistry/utils/utils.go new file mode 100644 index 000000000..905569586 --- /dev/null +++ b/internal/pkg/services/containerregistry/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/containerregistry/utils/utils_test.go b/internal/pkg/services/containerregistry/utils/utils_test.go new file mode 100644 index 000000000..6f069a727 --- /dev/null +++ b/internal/pkg/services/containerregistry/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) + } + }) + } +}