diff --git a/docs/stackit.md b/docs/stackit.md index e2f5c2689..dfbbadfa0 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -34,6 +34,8 @@ stackit [flags] * [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations * [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex * [stackit project](./stackit_project.md) - Provides functionality regarding projects +* [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ +* [stackit redis](./stackit_redis.md) - Provides functionality for Redis * [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts * [stackit ske](./stackit_ske.md) - Provides functionality for SKE diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 4e38c4db8..fc93b3c44 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -35,6 +35,8 @@ stackit config set [flags] --mongodbflex-custom-endpoint string MongoDB Flex custom endpoint --opensearch-custom-endpoint string OpenSearch custom endpoint --postgresflex-custom-endpoint string PostgreSQL Flex custom endpoint + --rabbitmq-custom-endpoint string RabbitMQ custom endpoint + --redis-custom-endpoint string Redis custom endpoint --resource-manager-custom-endpoint string Resource manager custom endpoint --service-account-custom-endpoint string Service Account custom endpoint --session-time-limit string Maximum time before authentication is required again. Can't be larger than 24h. Examples: 3h, 5h30m40s (BETA: currently values greater than 2h have no effect) diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index b399fe978..4895a8ef4 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -35,6 +35,8 @@ stackit config unset [flags] --output-format Output format --postgresflex-custom-endpoint PostgreSQL Flex custom endpoint --project-id Project ID + --rabbitmq-custom-endpoint RabbitMQ custom endpoint + --redis-custom-endpoint Redis custom endpoint --resource-manager-custom-endpoint Resource Manager custom endpoint --service-account-custom-endpoint SKE custom endpoint --ske-custom-endpoint SKE custom endpoint diff --git a/docs/stackit_postgresflex_instance_create.md b/docs/stackit_postgresflex_instance_create.md index c5f9fb0f7..5a9f24fcd 100644 --- a/docs/stackit_postgresflex_instance_create.md +++ b/docs/stackit_postgresflex_instance_create.md @@ -27,7 +27,7 @@ stackit postgresflex instance create [flags] ``` --acl strings The access control list (ACL). Must contain at least one valid subnet, for instance '0.0.0.0/0' for open access (discouraged), '1.2.3.0/24 for a public IP range of an organization, '1.2.3.4/32' for a single IP range, etc. (default []) - --backup-schedule string Backup schedule (default "0 0/6 * * *") + --backup-schedule string Backup schedule (default "0 0 * * *") --cpu int Number of CPUs --flavor-id string ID of the flavor -h, --help Help for "stackit postgresflex instance create" @@ -35,8 +35,8 @@ stackit postgresflex instance create [flags] --ram int Amount of RAM (in GB) --storage-class string Storage class (default "premium-perf2-stackit") --storage-size int Storage size (in GB) (default 10) - --type string Instance type, one of ["Single" "Replica" "Sharded"] (default "Replica") - --version string Version (default "6.0") + --type string Instance type, one of ["Single" "Replica"] (default "Replica") + --version string PostgreSQL version. Defaults to the latest version available ``` ### Options inherited from parent commands diff --git a/docs/stackit_postgresflex_instance_update.md b/docs/stackit_postgresflex_instance_update.md index 7196bd0b1..ce9711e73 100644 --- a/docs/stackit_postgresflex_instance_update.md +++ b/docs/stackit_postgresflex_instance_update.md @@ -32,7 +32,7 @@ stackit postgresflex instance update INSTANCE_ID [flags] --ram int Amount of RAM (in GB) --storage-class string Storage class --storage-size int Storage size (in GB) - --type string Instance type, one of ["Single" "Replica" "Sharded"] + --type string Instance type, one of ["Single" "Replica"] --version string Version ``` diff --git a/docs/stackit_rabbitmq.md b/docs/stackit_rabbitmq.md new file mode 100644 index 000000000..f4450278e --- /dev/null +++ b/docs/stackit_rabbitmq.md @@ -0,0 +1,34 @@ +## stackit rabbitmq + +Provides functionality for RabbitMQ + +### Synopsis + +Provides functionality for RabbitMQ. + +``` +stackit rabbitmq [flags] +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit rabbitmq credentials](./stackit_rabbitmq_credentials.md) - Provides functionality for RabbitMQ credentials +* [stackit rabbitmq instance](./stackit_rabbitmq_instance.md) - Provides functionality for RabbitMQ instances +* [stackit rabbitmq plans](./stackit_rabbitmq_plans.md) - Lists all RabbitMQ service plans + diff --git a/docs/stackit_rabbitmq_credentials.md b/docs/stackit_rabbitmq_credentials.md new file mode 100644 index 000000000..d093378eb --- /dev/null +++ b/docs/stackit_rabbitmq_credentials.md @@ -0,0 +1,35 @@ +## stackit rabbitmq credentials + +Provides functionality for RabbitMQ credentials + +### Synopsis + +Provides functionality for RabbitMQ credentials. + +``` +stackit rabbitmq credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq credentials" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ +* [stackit rabbitmq credentials create](./stackit_rabbitmq_credentials_create.md) - Creates credentials for an RabbitMQ instance +* [stackit rabbitmq credentials delete](./stackit_rabbitmq_credentials_delete.md) - Deletes credentials of an RabbitMQ instance +* [stackit rabbitmq credentials describe](./stackit_rabbitmq_credentials_describe.md) - Shows details of credentials of an RabbitMQ instance +* [stackit rabbitmq credentials list](./stackit_rabbitmq_credentials_list.md) - Lists all credentials' IDs for an RabbitMQ instance + diff --git a/docs/stackit_rabbitmq_credentials_create.md b/docs/stackit_rabbitmq_credentials_create.md new file mode 100644 index 000000000..fe20ab42b --- /dev/null +++ b/docs/stackit_rabbitmq_credentials_create.md @@ -0,0 +1,43 @@ +## stackit rabbitmq credentials create + +Creates credentials for an RabbitMQ instance + +### Synopsis + +Creates credentials (username and password) for an RabbitMQ instance. + +``` +stackit rabbitmq credentials create [flags] +``` + +### Examples + +``` + Create credentials for an RabbitMQ instance + $ stackit rabbitmq credentials create --instance-id xxx + + Create credentials for an RabbitMQ instance and hide the password in the output + $ stackit rabbitmq credentials create --instance-id xxx --hide-password +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq credentials create" + --hide-password Hide password in output + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq credentials](./stackit_rabbitmq_credentials.md) - Provides functionality for RabbitMQ credentials + diff --git a/docs/stackit_rabbitmq_credentials_delete.md b/docs/stackit_rabbitmq_credentials_delete.md new file mode 100644 index 000000000..96a90d9db --- /dev/null +++ b/docs/stackit_rabbitmq_credentials_delete.md @@ -0,0 +1,39 @@ +## stackit rabbitmq credentials delete + +Deletes credentials of an RabbitMQ instance + +### Synopsis + +Deletes credentials of an RabbitMQ instance. + +``` +stackit rabbitmq credentials delete CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Delete credentials with ID "xxx" of RabbitMQ instance with ID "yyy" + $ stackit rabbitmq credentials delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq credentials delete" + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq credentials](./stackit_rabbitmq_credentials.md) - Provides functionality for RabbitMQ credentials + diff --git a/docs/stackit_rabbitmq_credentials_describe.md b/docs/stackit_rabbitmq_credentials_describe.md new file mode 100644 index 000000000..1099c5a05 --- /dev/null +++ b/docs/stackit_rabbitmq_credentials_describe.md @@ -0,0 +1,42 @@ +## stackit rabbitmq credentials describe + +Shows details of credentials of an RabbitMQ instance + +### Synopsis + +Shows details of credentials of an RabbitMQ instance. The password will be shown in plain text in the output. + +``` +stackit rabbitmq credentials describe CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Get details of credentials of an RabbitMQ instance with ID "xxx" from instance with ID "yyy" + $ stackit rabbitmq credentials describe xxx --instance-id yyy + + Get details of credentials of an RabbitMQ instance with ID "xxx" from instance with ID "yyy" in a table format + $ stackit rabbitmq credentials describe xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq credentials describe" + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq credentials](./stackit_rabbitmq_credentials.md) - Provides functionality for RabbitMQ credentials + diff --git a/docs/stackit_rabbitmq_credentials_list.md b/docs/stackit_rabbitmq_credentials_list.md new file mode 100644 index 000000000..9be44e6c3 --- /dev/null +++ b/docs/stackit_rabbitmq_credentials_list.md @@ -0,0 +1,46 @@ +## stackit rabbitmq credentials list + +Lists all credentials' IDs for an RabbitMQ instance + +### Synopsis + +Lists all credentials' IDs for an RabbitMQ instance. + +``` +stackit rabbitmq credentials list [flags] +``` + +### Examples + +``` + List all credentials' IDs for an RabbitMQ instance + $ stackit rabbitmq credentials list --instance-id xxx + + List all credentials' IDs for an RabbitMQ instance in JSON format + $ stackit rabbitmq credentials list --instance-id xxx --output-format json + + List up to 10 credentials' IDs for an RabbitMQ instance + $ stackit rabbitmq credentials list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq credentials list" + --instance-id string Instance ID + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq credentials](./stackit_rabbitmq_credentials.md) - Provides functionality for RabbitMQ credentials + diff --git a/docs/stackit_rabbitmq_instance.md b/docs/stackit_rabbitmq_instance.md new file mode 100644 index 000000000..b574ef944 --- /dev/null +++ b/docs/stackit_rabbitmq_instance.md @@ -0,0 +1,36 @@ +## stackit rabbitmq instance + +Provides functionality for RabbitMQ instances + +### Synopsis + +Provides functionality for RabbitMQ instances. + +``` +stackit rabbitmq instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq instance" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ +* [stackit rabbitmq instance create](./stackit_rabbitmq_instance_create.md) - Creates an RabbitMQ instance +* [stackit rabbitmq instance delete](./stackit_rabbitmq_instance_delete.md) - Deletes an RabbitMQ instance +* [stackit rabbitmq instance describe](./stackit_rabbitmq_instance_describe.md) - Shows details of an RabbitMQ instance +* [stackit rabbitmq instance list](./stackit_rabbitmq_instance_list.md) - Lists all RabbitMQ instances +* [stackit rabbitmq instance update](./stackit_rabbitmq_instance_update.md) - Updates an RabbitMQ instance + diff --git a/docs/stackit_rabbitmq_instance_create.md b/docs/stackit_rabbitmq_instance_create.md new file mode 100644 index 000000000..4243e5519 --- /dev/null +++ b/docs/stackit_rabbitmq_instance_create.md @@ -0,0 +1,56 @@ +## stackit rabbitmq instance create + +Creates an RabbitMQ instance + +### Synopsis + +Creates an RabbitMQ instance. + +``` +stackit rabbitmq instance create [flags] +``` + +### Examples + +``` + Create an RabbitMQ instance with name "my-instance" and specify plan by name and version + $ stackit rabbitmq instance create --name my-instance --plan-name stackit-rabbitmq-1.2.10-replica --version 3.10 + + Create an RabbitMQ instance with name "my-instance" and specify plan by ID + $ stackit rabbitmq instance create --name my-instance --plan-id xxx + + Create an RabbitMQ instance with name "my-instance" and specify IP range which is allowed to access it + $ stackit rabbitmq instance create --name my-instance --plan-id xxx --acl 192.168.1.0/24 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --enable-monitoring Enable monitoring + --graphite string Graphite host + -h, --help Help for "stackit rabbitmq instance create" + --metrics-frequency int Metrics frequency + --metrics-prefix string Metrics prefix + --monitoring-instance-id string Monitoring instance ID + -n, --name string Instance name + --plan-id string Plan ID + --plan-name string Plan name + --plugin strings Plugin + --syslog strings Syslog + --version string Instance RabbitMQ version +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq instance](./stackit_rabbitmq_instance.md) - Provides functionality for RabbitMQ instances + diff --git a/docs/stackit_rabbitmq_instance_delete.md b/docs/stackit_rabbitmq_instance_delete.md new file mode 100644 index 000000000..dabb3db8a --- /dev/null +++ b/docs/stackit_rabbitmq_instance_delete.md @@ -0,0 +1,38 @@ +## stackit rabbitmq instance delete + +Deletes an RabbitMQ instance + +### Synopsis + +Deletes an RabbitMQ instance. + +``` +stackit rabbitmq instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete an RabbitMQ instance with ID "xxx" + $ stackit rabbitmq instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq instance 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq instance](./stackit_rabbitmq_instance.md) - Provides functionality for RabbitMQ instances + diff --git a/docs/stackit_rabbitmq_instance_describe.md b/docs/stackit_rabbitmq_instance_describe.md new file mode 100644 index 000000000..c6d3a9630 --- /dev/null +++ b/docs/stackit_rabbitmq_instance_describe.md @@ -0,0 +1,41 @@ +## stackit rabbitmq instance describe + +Shows details of an RabbitMQ instance + +### Synopsis + +Shows details of an RabbitMQ instance. + +``` +stackit rabbitmq instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of an RabbitMQ instance with ID "xxx" + $ stackit rabbitmq instance describe xxx + + Get details of an RabbitMQ instance with ID "xxx" in a table format + $ stackit rabbitmq instance describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq instance 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq instance](./stackit_rabbitmq_instance.md) - Provides functionality for RabbitMQ instances + diff --git a/docs/stackit_rabbitmq_instance_list.md b/docs/stackit_rabbitmq_instance_list.md new file mode 100644 index 000000000..8cb95d860 --- /dev/null +++ b/docs/stackit_rabbitmq_instance_list.md @@ -0,0 +1,45 @@ +## stackit rabbitmq instance list + +Lists all RabbitMQ instances + +### Synopsis + +Lists all RabbitMQ instances. + +``` +stackit rabbitmq instance list [flags] +``` + +### Examples + +``` + List all RabbitMQ instances + $ stackit rabbitmq instance list + + List all RabbitMQ instances in JSON format + $ stackit rabbitmq instance list --output-format json + + List up to 10 RabbitMQ instances + $ stackit rabbitmq instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq instance 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq instance](./stackit_rabbitmq_instance.md) - Provides functionality for RabbitMQ instances + diff --git a/docs/stackit_rabbitmq_instance_update.md b/docs/stackit_rabbitmq_instance_update.md new file mode 100644 index 000000000..f962420aa --- /dev/null +++ b/docs/stackit_rabbitmq_instance_update.md @@ -0,0 +1,52 @@ +## stackit rabbitmq instance update + +Updates an RabbitMQ instance + +### Synopsis + +Updates an RabbitMQ instance. + +``` +stackit rabbitmq instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the plan of an RabbitMQ instance with ID "xxx" + $ stackit rabbitmq instance update xxx --plan-id xxx + + Update the range of IPs allowed to access an RabbitMQ instance with ID "xxx" + $ stackit rabbitmq instance update xxx --acl 192.168.1.0/24 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --enable-monitoring Enable monitoring + --graphite string Graphite host + -h, --help Help for "stackit rabbitmq instance update" + --metrics-frequency int Metrics frequency + --metrics-prefix string Metrics prefix + --monitoring-instance-id string Monitoring instance ID + --plan-id string Plan ID + --plan-name string Plan name + --plugin strings Plugin + --syslog strings Syslog + --version string Instance RabbitMQ version +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq instance](./stackit_rabbitmq_instance.md) - Provides functionality for RabbitMQ instances + diff --git a/docs/stackit_rabbitmq_plans.md b/docs/stackit_rabbitmq_plans.md new file mode 100644 index 000000000..bf41ffbe8 --- /dev/null +++ b/docs/stackit_rabbitmq_plans.md @@ -0,0 +1,45 @@ +## stackit rabbitmq plans + +Lists all RabbitMQ service plans + +### Synopsis + +Lists all RabbitMQ service plans. + +``` +stackit rabbitmq plans [flags] +``` + +### Examples + +``` + List all RabbitMQ service plans + $ stackit rabbitmq plans + + List all RabbitMQ service plans in JSON format + $ stackit rabbitmq plans --output-format json + + List up to 10 RabbitMQ service plans + $ stackit rabbitmq plans --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit rabbitmq plans" + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ + diff --git a/docs/stackit_redis.md b/docs/stackit_redis.md new file mode 100644 index 000000000..8fa3bb4c3 --- /dev/null +++ b/docs/stackit_redis.md @@ -0,0 +1,34 @@ +## stackit redis + +Provides functionality for Redis + +### Synopsis + +Provides functionality for Redis. + +``` +stackit redis [flags] +``` + +### Options + +``` + -h, --help Help for "stackit redis" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit redis credentials](./stackit_redis_credentials.md) - Provides functionality for Redis credentials +* [stackit redis instance](./stackit_redis_instance.md) - Provides functionality for Redis instances +* [stackit redis plans](./stackit_redis_plans.md) - Lists all Redis service plans + diff --git a/docs/stackit_redis_credentials.md b/docs/stackit_redis_credentials.md new file mode 100644 index 000000000..31d9245f0 --- /dev/null +++ b/docs/stackit_redis_credentials.md @@ -0,0 +1,35 @@ +## stackit redis credentials + +Provides functionality for Redis credentials + +### Synopsis + +Provides functionality for Redis credentials. + +``` +stackit redis credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit redis credentials" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis](./stackit_redis.md) - Provides functionality for Redis +* [stackit redis credentials create](./stackit_redis_credentials_create.md) - Creates credentials for a Redis instance +* [stackit redis credentials delete](./stackit_redis_credentials_delete.md) - Deletes credentials of a Redis instance +* [stackit redis credentials describe](./stackit_redis_credentials_describe.md) - Shows details of credentials of a Redis instance +* [stackit redis credentials list](./stackit_redis_credentials_list.md) - Lists all credentials' IDs for a Redis instance + diff --git a/docs/stackit_redis_credentials_create.md b/docs/stackit_redis_credentials_create.md new file mode 100644 index 000000000..7d0117244 --- /dev/null +++ b/docs/stackit_redis_credentials_create.md @@ -0,0 +1,43 @@ +## stackit redis credentials create + +Creates credentials for a Redis instance + +### Synopsis + +Creates credentials (username and password) for a Redis instance. + +``` +stackit redis credentials create [flags] +``` + +### Examples + +``` + Create credentials for a Redis instance + $ stackit redis credentials create --instance-id xxx + + Create credentials for a Redis instance and hide the password in the output + $ stackit redis credentials create --instance-id xxx --hide-password +``` + +### Options + +``` + -h, --help Help for "stackit redis credentials create" + --hide-password Hide password in output + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis credentials](./stackit_redis_credentials.md) - Provides functionality for Redis credentials + diff --git a/docs/stackit_redis_credentials_delete.md b/docs/stackit_redis_credentials_delete.md new file mode 100644 index 000000000..fb4a1bd72 --- /dev/null +++ b/docs/stackit_redis_credentials_delete.md @@ -0,0 +1,39 @@ +## stackit redis credentials delete + +Deletes credentials of a Redis instance + +### Synopsis + +Deletes credentials of a Redis instance. + +``` +stackit redis credentials delete CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Delete credentials with ID "xxx" of Redis instance with ID "yyy" + $ stackit redis credentials delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit redis credentials delete" + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis credentials](./stackit_redis_credentials.md) - Provides functionality for Redis credentials + diff --git a/docs/stackit_redis_credentials_describe.md b/docs/stackit_redis_credentials_describe.md new file mode 100644 index 000000000..36b72138a --- /dev/null +++ b/docs/stackit_redis_credentials_describe.md @@ -0,0 +1,42 @@ +## stackit redis credentials describe + +Shows details of credentials of a Redis instance + +### Synopsis + +Shows details of credentials of a Redis instance. The password will be shown in plain text in the output. + +``` +stackit redis credentials describe CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Get details of credentials of a Redis instance with ID "xxx" from instance with ID "yyy" + $ stackit redis credentials describe xxx --instance-id yyy + + Get details of credentials of a Redis instance with ID "xxx" from instance with ID "yyy" in a table format + $ stackit redis credentials describe xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit redis credentials describe" + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis credentials](./stackit_redis_credentials.md) - Provides functionality for Redis credentials + diff --git a/docs/stackit_redis_credentials_list.md b/docs/stackit_redis_credentials_list.md new file mode 100644 index 000000000..31e0ab33f --- /dev/null +++ b/docs/stackit_redis_credentials_list.md @@ -0,0 +1,46 @@ +## stackit redis credentials list + +Lists all credentials' IDs for a Redis instance + +### Synopsis + +Lists all credentials' IDs for a Redis instance. + +``` +stackit redis credentials list [flags] +``` + +### Examples + +``` + List all credentials' IDs for a Redis instance + $ stackit redis credentials list --instance-id xxx + + List all credentials' IDs for a Redis instance in JSON format + $ stackit redis credentials list --instance-id xxx --output-format json + + List up to 10 credentials' IDs for a Redis instance + $ stackit redis credentials list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit redis credentials list" + --instance-id string Instance ID + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis credentials](./stackit_redis_credentials.md) - Provides functionality for Redis credentials + diff --git a/docs/stackit_redis_instance.md b/docs/stackit_redis_instance.md new file mode 100644 index 000000000..59e565f7e --- /dev/null +++ b/docs/stackit_redis_instance.md @@ -0,0 +1,36 @@ +## stackit redis instance + +Provides functionality for Redis instances + +### Synopsis + +Provides functionality for Redis instances. + +``` +stackit redis instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit redis instance" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis](./stackit_redis.md) - Provides functionality for Redis +* [stackit redis instance create](./stackit_redis_instance_create.md) - Creates a Redis instance +* [stackit redis instance delete](./stackit_redis_instance_delete.md) - Deletes a Redis instance +* [stackit redis instance describe](./stackit_redis_instance_describe.md) - Shows details of a Redis instance +* [stackit redis instance list](./stackit_redis_instance_list.md) - Lists all Redis instances +* [stackit redis instance update](./stackit_redis_instance_update.md) - Updates a Redis instance + diff --git a/docs/stackit_redis_instance_create.md b/docs/stackit_redis_instance_create.md new file mode 100644 index 000000000..20bba5da7 --- /dev/null +++ b/docs/stackit_redis_instance_create.md @@ -0,0 +1,56 @@ +## stackit redis instance create + +Creates a Redis instance + +### Synopsis + +Creates a Redis instance. + +``` +stackit redis instance create [flags] +``` + +### Examples + +``` + Create a Redis instance with name "my-instance" and specify plan by name and version + $ stackit redis instance create --name my-instance --plan-name stackit-redis-1.2.10-replica --version 6 + + Create a Redis instance with name "my-instance" and specify plan by ID + $ stackit redis instance create --name my-instance --plan-id xxx + + Create a Redis instance with name "my-instance" and specify IP range which is allowed to access it + $ stackit redis instance create --name my-instance --plan-id xxx --acl 192.168.1.0/24 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --enable-monitoring Enable monitoring + --graphite string Graphite host + -h, --help Help for "stackit redis instance create" + --metrics-frequency int Metrics frequency + --metrics-prefix string Metrics prefix + --monitoring-instance-id string Monitoring instance ID + -n, --name string Instance name + --plan-id string Plan ID + --plan-name string Plan name + --plugin strings Plugin + --syslog strings Syslog + --version string Instance Redis version +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis instance](./stackit_redis_instance.md) - Provides functionality for Redis instances + diff --git a/docs/stackit_redis_instance_delete.md b/docs/stackit_redis_instance_delete.md new file mode 100644 index 000000000..fda22976d --- /dev/null +++ b/docs/stackit_redis_instance_delete.md @@ -0,0 +1,38 @@ +## stackit redis instance delete + +Deletes a Redis instance + +### Synopsis + +Deletes a Redis instance. + +``` +stackit redis instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a Redis instance with ID "xxx" + $ stackit redis instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit redis instance 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis instance](./stackit_redis_instance.md) - Provides functionality for Redis instances + diff --git a/docs/stackit_redis_instance_describe.md b/docs/stackit_redis_instance_describe.md new file mode 100644 index 000000000..896d64748 --- /dev/null +++ b/docs/stackit_redis_instance_describe.md @@ -0,0 +1,41 @@ +## stackit redis instance describe + +Shows details of a Redis instance + +### Synopsis + +Shows details of a Redis instance. + +``` +stackit redis instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a Redis instance with ID "xxx" + $ stackit redis instance describe xxx + + Get details of a Redis instance with ID "xxx" in a table format + $ stackit redis instance describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit redis instance 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis instance](./stackit_redis_instance.md) - Provides functionality for Redis instances + diff --git a/docs/stackit_redis_instance_list.md b/docs/stackit_redis_instance_list.md new file mode 100644 index 000000000..bc59c1a84 --- /dev/null +++ b/docs/stackit_redis_instance_list.md @@ -0,0 +1,45 @@ +## stackit redis instance list + +Lists all Redis instances + +### Synopsis + +Lists all Redis instances. + +``` +stackit redis instance list [flags] +``` + +### Examples + +``` + List all Redis instances + $ stackit redis instance list + + List all Redis instances in JSON format + $ stackit redis instance list --output-format json + + List up to 10 Redis instances + $ stackit redis instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit redis instance 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis instance](./stackit_redis_instance.md) - Provides functionality for Redis instances + diff --git a/docs/stackit_redis_instance_update.md b/docs/stackit_redis_instance_update.md new file mode 100644 index 000000000..aa2e36858 --- /dev/null +++ b/docs/stackit_redis_instance_update.md @@ -0,0 +1,52 @@ +## stackit redis instance update + +Updates a Redis instance + +### Synopsis + +Updates a Redis instance. + +``` +stackit redis instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the plan of a Redis instance with ID "xxx" + $ stackit redis instance update xxx --plan-id xxx + + Update the range of IPs allowed to access a Redis instance with ID "xxx" + $ stackit redis instance update xxx --acl 192.168.1.0/24 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --enable-monitoring Enable monitoring + --graphite string Graphite host + -h, --help Help for "stackit redis instance update" + --metrics-frequency int Metrics frequency + --metrics-prefix string Metrics prefix + --monitoring-instance-id string Monitoring instance ID + --plan-id string Plan ID + --plan-name string Plan name + --plugin strings Plugin + --syslog strings Syslog + --version string Instance Redis version +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis instance](./stackit_redis_instance.md) - Provides functionality for Redis instances + diff --git a/docs/stackit_redis_plans.md b/docs/stackit_redis_plans.md new file mode 100644 index 000000000..6ad8a3afe --- /dev/null +++ b/docs/stackit_redis_plans.md @@ -0,0 +1,45 @@ +## stackit redis plans + +Lists all Redis service plans + +### Synopsis + +Lists all Redis service plans. + +``` +stackit redis plans [flags] +``` + +### Examples + +``` + List all Redis service plans + $ stackit redis plans + + List all Redis service plans in JSON format + $ stackit redis plans --output-format json + + List up to 10 Redis service plans + $ stackit redis plans --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit redis plans" + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit redis](./stackit_redis.md) - Provides functionality for Redis + diff --git a/go.mod b/go.mod index c95165801..279c0471c 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,8 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect + github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.10.0 + github.com/stackitcloud/stackit-sdk-go/services/redis v0.10.0 github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect diff --git a/go.sum b/go.sum index 85f4032e2..4667e305c 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,10 @@ github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.9.2 h1:dwZ1NDD+AxT github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.9.2/go.mod h1:M8mjTS5yR0XXoH9EpuULme9fEkLhUz4UOT7XSHUSRQ8= github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.9.0 h1:b4RFEDSMa+p8iUsMS/RzhkT6uLhD607lhQyQPfrO8nU= github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.9.0/go.mod h1:bVXMSjQH1eGUF5IoPAD8BjAzhRMunDlnrwLsaCPgRQ4= +github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.10.0 h1:Fle394socpyf662g3jMrtZpZaWVgBMBIEFnh4fnGock= +github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.10.0/go.mod h1:JvqOSrTCiynS0x6Y9OsK54yvdB6AtIWLwXDEjoCkAIg= +github.com/stackitcloud/stackit-sdk-go/services/redis v0.10.0 h1:4u+OiVDAI6Hpej81e+YrbzN5FT4l7ZrTw+eSRcQnCYg= +github.com/stackitcloud/stackit-sdk-go/services/redis v0.10.0/go.mod h1:N92nAI+e4qKwCvDRI5kg9Q2g6COf9ganqp4pMlsKOYc= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.7.5 h1:Gu0z8MpErzBHxb9xx8B/4DduxckDmBRPWNaeoVcE8cQ= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.7.5/go.mod h1:MQ5eGWFmnDf9wUArqZ2g+nwJgMDkYDQUkoRVutaHrms= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.4 h1:XNL7bk5mwCovV8a3oIIC9PlNpPTUG3XNwdRqHS5V2no= diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index c7210db71..90b72c8e6 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -23,6 +23,8 @@ const ( mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" openSearchCustomEndpointFlag = "opensearch-custom-endpoint" postgresFlexCustomEndpointFlag = "postgresflex-custom-endpoint" + rabbitMQCustomEndpointFlag = "rabbitmq-custom-endpoint" + redisCustomEndpointFlag = "redis-custom-endpoint" resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" serviceAccountCustomEndpointFlag = "service-account-custom-endpoint" skeCustomEndpointFlag = "ske-custom-endpoint" @@ -91,6 +93,8 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(mongoDBFlexCustomEndpointFlag, "", "MongoDB Flex custom endpoint") cmd.Flags().String(openSearchCustomEndpointFlag, "", "OpenSearch custom endpoint") cmd.Flags().String(postgresFlexCustomEndpointFlag, "", "PostgreSQL Flex custom endpoint") + cmd.Flags().String(rabbitMQCustomEndpointFlag, "", "RabbitMQ custom endpoint") + cmd.Flags().String(redisCustomEndpointFlag, "", "Redis custom endpoint") cmd.Flags().String(resourceManagerCustomEndpointFlag, "", "Resource manager custom endpoint") cmd.Flags().String(serviceAccountCustomEndpointFlag, "", "Service Account custom endpoint") cmd.Flags().String(skeCustomEndpointFlag, "", "SKE custom endpoint") @@ -111,6 +115,10 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.SKECustomEndpointKey, cmd.Flags().Lookup(skeCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.RedisCustomEndpointKey, cmd.Flags().Lookup(redisCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.RabbitMQCustomEndpointKey, cmd.Flags().Lookup(rabbitMQCustomEndpointFlag)) + cobra.CheckErr(err) } func parseInput(cmd *cobra.Command) (*inputModel, error) { diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 74d5fae22..490def433 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -23,6 +23,8 @@ const ( mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" openSearchCustomEndpointFlag = "opensearch-custom-endpoint" postgresFlexCustomEndpointFlag = "postgresflex-custom-endpoint" + rabbitMQCustomEndpointFlag = "rabbitmq-custom-endpoint" + redisCustomEndpointFlag = "redis-custom-endpoint" resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" serviceAccountCustomEndpointFlag = "service-account-custom-endpoint" skeCustomEndpointFlag = "ske-custom-endpoint" @@ -38,6 +40,8 @@ type inputModel struct { MongoDBFlexCustomEndpoint bool OpenSearchCustomEndpoint bool PostgresFlexCustomEndpoint bool + RabbitMQCustomEndpoint bool + RedisCustomEndpoint bool ResourceManagerCustomEndpoint bool ServiceAccountCustomEndpoint bool SKECustomEndpoint bool @@ -76,20 +80,26 @@ func NewCmd() *cobra.Command { if model.DNSCustomEndpoint { viper.Set(config.DNSCustomEndpointKey, "") } + if model.MembershipCustomEndpoint { + viper.Set(config.MembershipCustomEndpointKey, "") + } + if model.MongoDBFlexCustomEndpoint { + viper.Set(config.MongoDBFlexCustomEndpointKey, "") + } if model.OpenSearchCustomEndpoint { viper.Set(config.OpenSearchCustomEndpointKey, "") } if model.PostgresFlexCustomEndpoint { viper.Set(config.PostgresFlexCustomEndpointKey, "") } - if model.ResourceManagerCustomEndpoint { - viper.Set(config.ResourceManagerEndpointKey, "") + if model.RabbitMQCustomEndpoint { + viper.Set(config.RabbitMQCustomEndpointKey, "") } - if model.MembershipCustomEndpoint { - viper.Set(config.MembershipCustomEndpointKey, "") + if model.RedisCustomEndpoint { + viper.Set(config.RedisCustomEndpointKey, "") } - if model.MongoDBFlexCustomEndpoint { - viper.Set(config.MongoDBFlexCustomEndpointKey, "") + if model.ResourceManagerCustomEndpoint { + viper.Set(config.ResourceManagerEndpointKey, "") } if model.ServiceAccountCustomEndpoint { viper.Set(config.ServiceAccountCustomEndpointKey, "") @@ -119,6 +129,8 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(mongoDBFlexCustomEndpointFlag, false, "MongoDB Flex custom endpoint") cmd.Flags().Bool(openSearchCustomEndpointFlag, false, "OpenSearch custom endpoint") cmd.Flags().Bool(postgresFlexCustomEndpointFlag, false, "PostgreSQL Flex custom endpoint") + cmd.Flags().Bool(rabbitMQCustomEndpointFlag, false, "RabbitMQ custom endpoint") + cmd.Flags().Bool(redisCustomEndpointFlag, false, "Redis custom endpoint") cmd.Flags().Bool(resourceManagerCustomEndpointFlag, false, "Resource Manager custom endpoint") cmd.Flags().Bool(serviceAccountCustomEndpointFlag, false, "SKE custom endpoint") cmd.Flags().Bool(skeCustomEndpointFlag, false, "SKE custom endpoint") @@ -135,6 +147,8 @@ func parseInput(cmd *cobra.Command) *inputModel { MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(cmd, mongoDBFlexCustomEndpointFlag), OpenSearchCustomEndpoint: flags.FlagToBoolValue(cmd, openSearchCustomEndpointFlag), PostgresFlexCustomEndpoint: flags.FlagToBoolValue(cmd, postgresFlexCustomEndpointFlag), + RabbitMQCustomEndpoint: flags.FlagToBoolValue(cmd, rabbitMQCustomEndpointFlag), + RedisCustomEndpoint: flags.FlagToBoolValue(cmd, redisCustomEndpointFlag), ResourceManagerCustomEndpoint: flags.FlagToBoolValue(cmd, resourceManagerCustomEndpointFlag), ServiceAccountCustomEndpoint: flags.FlagToBoolValue(cmd, serviceAccountCustomEndpointFlag), SKECustomEndpoint: flags.FlagToBoolValue(cmd, skeCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 014c3d124..d7fdc7ee2 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -14,6 +14,8 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool dnsCustomEndpointFlag: true, openSearchCustomEndpointFlag: true, + rabbitMQCustomEndpointFlag: true, + redisCustomEndpointFlag: true, resourceManagerCustomEndpointFlag: true, serviceAccountCustomEndpointFlag: true, skeCustomEndpointFlag: true, @@ -33,6 +35,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { SKECustomEndpoint: true, ResourceManagerCustomEndpoint: true, OpenSearchCustomEndpoint: true, + RedisCustomEndpoint: true, + RabbitMQCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -65,6 +69,8 @@ func TestParseInput(t *testing.T) { model.SKECustomEndpoint = false model.ResourceManagerCustomEndpoint = false model.OpenSearchCustomEndpoint = false + model.RedisCustomEndpoint = false + model.RabbitMQCustomEndpoint = false }), }, { diff --git a/internal/cmd/opensearch/instance/create/create.go b/internal/cmd/opensearch/instance/create/create.go index c62218585..f523127b5 100644 --- a/internal/cmd/opensearch/instance/create/create.go +++ b/internal/cmd/opensearch/instance/create/create.go @@ -198,7 +198,6 @@ type openSearchClient interface { } func buildRequest(ctx context.Context, model *inputModel, apiClient openSearchClient) (opensearch.ApiCreateInstanceRequest, error) { - service := "opensearch" req := apiClient.CreateInstance(ctx, model.ProjectId) var planId *string @@ -219,7 +218,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient openSearchCl return req, err } } else { - err := opensearchUtils.ValidatePlanId(service, *model.PlanId, offerings) + err := opensearchUtils.ValidatePlanId(*model.PlanId, offerings) if err != nil { return req, err } diff --git a/internal/cmd/opensearch/instance/update/update.go b/internal/cmd/opensearch/instance/update/update.go index c5f96e800..c432147ac 100644 --- a/internal/cmd/opensearch/instance/update/update.go +++ b/internal/cmd/opensearch/instance/update/update.go @@ -205,8 +205,6 @@ type openSearchClient interface { } func buildRequest(ctx context.Context, model *inputModel, apiClient openSearchClient) (opensearch.ApiPartialUpdateInstanceRequest, error) { - service := "opensearch" - req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId) var planId *string @@ -229,7 +227,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient openSearchCl } else { // planId is not required for update operation if model.PlanId != nil { - err := opensearchUtils.ValidatePlanId(service, *model.PlanId, offerings) + err := opensearchUtils.ValidatePlanId(*model.PlanId, offerings) if err != nil { return req, err } diff --git a/internal/cmd/rabbitmq/credentials/create/create.go b/internal/cmd/rabbitmq/credentials/create/create.go new file mode 100644 index 000000000..fa25bfe19 --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/create/create.go @@ -0,0 +1,119 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/services/rabbitmq/client" + rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + instanceIdFlag = "instance-id" + hidePasswordFlag = "hide-password" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + HidePassword bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates credentials for an RabbitMQ instance", + Long: "Creates credentials (username and password) for an RabbitMQ instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create credentials for an RabbitMQ instance`, + "$ stackit rabbitmq credentials create --instance-id xxx"), + examples.NewExample( + `Create credentials for an RabbitMQ instance and hide the password in the output`, + "$ stackit rabbitmq credentials create --instance-id xxx --hide-password"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %s?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create RabbitMQ credentials: %w", err) + } + + cmd.Printf("Created credentials for instance %s. Credentials ID: %s\n\n", instanceLabel, *resp.Id) + cmd.Printf("Username: %s\n", *resp.Raw.Credentials.Username) + if model.HidePassword { + cmd.Printf("Password: \n") + } else { + cmd.Printf("Password: %s\n", *resp.Raw.Credentials.Password) + } + cmd.Printf("Host: %s\n", *resp.Raw.Credentials.Host) + cmd.Printf("Port: %d\n", *resp.Raw.Credentials.Port) + cmd.Printf("URI: %s\n", *resp.Uri) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Bool(hidePasswordFlag, false, "Hide password in output") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + HidePassword: flags.FlagToBoolValue(cmd, hidePasswordFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiCreateCredentialsRequest { + req := apiClient.CreateCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/rabbitmq/credentials/create/create_test.go b/internal/cmd/rabbitmq/credentials/create/create_test.go new file mode 100644 index 000000000..898311b52 --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/create/create_test.go @@ -0,0 +1,189 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiCreateCredentialsRequest)) rabbitmq.ApiCreateCredentialsRequest { + request := testClient.CreateCredentials(testCtx, testProjectId, testInstanceId) + 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: "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: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiCreateCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/credentials/credentials.go b/internal/cmd/rabbitmq/credentials/credentials.go new file mode 100644 index 000000000..e6fd55a7e --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/credentials.go @@ -0,0 +1,31 @@ +package credentials + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "credentials", + Short: "Provides functionality for RabbitMQ credentials", + Long: "Provides functionality for RabbitMQ credentials.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) +} diff --git a/internal/cmd/rabbitmq/credentials/delete/delete.go b/internal/cmd/rabbitmq/credentials/delete/delete.go new file mode 100644 index 000000000..7a5952b4f --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/delete/delete.go @@ -0,0 +1,115 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/services/rabbitmq/client" + rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + credentialsIdArg = "CREDENTIALS_ID" //nolint:gosec // linter false positive + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + CredentialsId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", credentialsIdArg), + Short: "Deletes credentials of an RabbitMQ instance", + Long: "Deletes credentials of an RabbitMQ instance.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete credentials with ID "xxx" of RabbitMQ instance with ID "yyy"`, + "$ stackit rabbitmq credentials delete xxx --instance-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + credentialsLabel, err := rabbitmqUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) + if err != nil { + credentialsLabel = model.CredentialsId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %s? (This cannot be undone)", credentialsLabel, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete RabbitMQ credentials: %w", err) + } + + cmd.Printf("Deleted credentials %s of instance %s\n", credentialsLabel, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + CredentialsId: credentialsId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiDeleteCredentialsRequest { + req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} diff --git a/internal/cmd/rabbitmq/credentials/delete/delete_test.go b/internal/cmd/rabbitmq/credentials/delete/delete_test.go new file mode 100644 index 000000000..c9b53315d --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/delete/delete_test.go @@ -0,0 +1,242 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testCredentialsId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + CredentialsId: testCredentialsId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiDeleteCredentialsRequest)) rabbitmq.ApiDeleteCredentialsRequest { + request := testClient.DeleteCredentials(testCtx, testProjectId, testInstanceId, testCredentialsId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "credentials id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "credentials id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiDeleteCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/credentials/describe/describe.go b/internal/cmd/rabbitmq/credentials/describe/describe.go new file mode 100644 index 000000000..d152a8dd4 --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/describe/describe.go @@ -0,0 +1,127 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "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/services/rabbitmq/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + credentialsIdArg = "CREDENTIALS_ID" //nolint:gosec // linter false positive + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + CredentialsId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", credentialsIdArg), + Short: "Shows details of credentials of an RabbitMQ instance", + Long: "Shows details of credentials of an RabbitMQ instance. The password will be shown in plain text in the output.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of credentials of an RabbitMQ instance with ID "xxx" from instance with ID "yyy"`, + "$ stackit rabbitmq credentials describe xxx --instance-id yyy"), + examples.NewExample( + `Get details of credentials of an RabbitMQ instance with ID "xxx" from instance with ID "yyy" in a table format`, + "$ stackit rabbitmq credentials describe xxx --instance-id yyy --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe RabbitMQ credentials: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + CredentialsId: credentialsId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiGetCredentialsRequest { + req := apiClient.GetCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials *rabbitmq.CredentialsResponse) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *credentials.Id) + table.AddSeparator() + table.AddRow("USERNAME", *credentials.Raw.Credentials.Username) + table.AddSeparator() + table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password) + table.AddSeparator() + table.AddRow("URI", *credentials.Raw.Credentials.Uri) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal RabbitMQ credentials: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/rabbitmq/credentials/describe/describe_test.go b/internal/cmd/rabbitmq/credentials/describe/describe_test.go new file mode 100644 index 000000000..b71162bb4 --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/describe/describe_test.go @@ -0,0 +1,242 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testCredentialsId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + CredentialsId: testCredentialsId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiGetCredentialsRequest)) rabbitmq.ApiGetCredentialsRequest { + request := testClient.GetCredentials(testCtx, testProjectId, testInstanceId, testCredentialsId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "credentials id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "credentials id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiGetCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/credentials/list/list.go b/internal/cmd/rabbitmq/credentials/list/list.go new file mode 100644 index 000000000..0dd1e9c54 --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/list/list.go @@ -0,0 +1,147 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "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/services/rabbitmq/client" + rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + instanceIdFlag = "instance-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all credentials' IDs for an RabbitMQ instance", + Long: "Lists all credentials' IDs for an RabbitMQ instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all credentials' IDs for an RabbitMQ instance`, + "$ stackit rabbitmq credentials list --instance-id xxx"), + examples.NewExample( + `List all credentials' IDs for an RabbitMQ instance in JSON format`, + "$ stackit rabbitmq credentials list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 credentials' IDs for an RabbitMQ instance`, + "$ stackit rabbitmq credentials list --instance-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list RabbitMQ credentialss: %w", err) + } + credentials := *resp.CredentialsList + if len(credentials) == 0 { + instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + cmd.Printf("No credentials found for instance %s\n", instanceLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(credentials) > int(*model.Limit) { + credentials = credentials[:*model.Limit] + } + return outputResult(cmd, model.OutputFormat, credentials) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials []rabbitmq.CredentialsListItem) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal RabbitMQ credentials list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID") + for i := range credentials { + c := credentials[i] + table.AddRow(*c.Id) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/rabbitmq/credentials/list/list_test.go b/internal/cmd/rabbitmq/credentials/list/list_test.go new file mode 100644 index 000000000..e8d2f9b24 --- /dev/null +++ b/internal/cmd/rabbitmq/credentials/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiListCredentialsRequest)) rabbitmq.ApiListCredentialsRequest { + request := testClient.ListCredentials(testCtx, testProjectId, testInstanceId) + 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: "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: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiListCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/instance/create/create.go b/internal/cmd/rabbitmq/instance/create/create.go new file mode 100644 index 000000000..8b694a9b9 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/create/create.go @@ -0,0 +1,248 @@ +package create + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + 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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/client" + rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq/wait" +) + +const ( + instanceNameFlag = "name" + enableMonitoringFlag = "enable-monitoring" + graphiteFlag = "graphite" + metricsFrequencyFlag = "metrics-frequency" + metricsPrefixFlag = "metrics-prefix" + monitoringInstanceIdFlag = "monitoring-instance-id" + pluginFlag = "plugin" + sgwAclFlag = "acl" + syslogFlag = "syslog" + planIdFlag = "plan-id" + planNameFlag = "plan-name" + versionFlag = "version" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlanName string + Version string + + InstanceName *string + EnableMonitoring *bool + Graphite *string + MetricsFrequency *int64 + MetricsPrefix *string + MonitoringInstanceId *string + Plugin *[]string + SgwAcl *[]string + Syslog *[]string + PlanId *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an RabbitMQ instance", + Long: "Creates an RabbitMQ instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an RabbitMQ instance with name "my-instance" and specify plan by name and version`, + "$ stackit rabbitmq instance create --name my-instance --plan-name stackit-rabbitmq-1.2.10-replica --version 3.10"), + examples.NewExample( + `Create an RabbitMQ instance with name "my-instance" and specify plan by ID`, + "$ stackit rabbitmq instance create --name my-instance --plan-id xxx"), + examples.NewExample( + `Create an RabbitMQ instance with name "my-instance" and specify IP range which is allowed to access it`, + "$ stackit rabbitmq instance create --name my-instance --plan-id xxx --acl 192.168.1.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create an RabbitMQ instance for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return fmt.Errorf("build RabbitMQ instance creation request: %w", err) + } + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create RabbitMQ instance: %w", err) + } + instanceId := *resp.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating instance") + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for RabbitMQ instance creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s instance for project %s. Instance ID: %s\n", operationState, projectLabel, instanceId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") + cmd.Flags().Bool(enableMonitoringFlag, false, "Enable monitoring") + cmd.Flags().String(graphiteFlag, "", "Graphite host") + cmd.Flags().Int64(metricsFrequencyFlag, 0, "Metrics frequency") + cmd.Flags().String(metricsPrefixFlag, "", "Metrics prefix") + cmd.Flags().Var(flags.UUIDFlag(), monitoringInstanceIdFlag, "Monitoring instance ID") + cmd.Flags().StringSlice(pluginFlag, []string{}, "Plugin") + cmd.Flags().Var(flags.CIDRSliceFlag(), sgwAclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().StringSlice(syslogFlag, []string{}, "Syslog") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(versionFlag, "", "Instance RabbitMQ version") + + err := flags.MarkFlagsRequired(cmd, instanceNameFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + planId := flags.FlagToStringPointer(cmd, planIdFlag) + planName := flags.FlagToStringValue(cmd, planNameFlag) + version := flags.FlagToStringValue(cmd, versionFlag) + + if planId == nil && (planName == "" || version == "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + if planId != nil && (planName != "" || version != "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceName: flags.FlagToStringPointer(cmd, instanceNameFlag), + EnableMonitoring: flags.FlagToBoolPointer(cmd, enableMonitoringFlag), + MonitoringInstanceId: flags.FlagToStringPointer(cmd, monitoringInstanceIdFlag), + Graphite: flags.FlagToStringPointer(cmd, graphiteFlag), + MetricsFrequency: flags.FlagToInt64Pointer(cmd, metricsFrequencyFlag), + MetricsPrefix: flags.FlagToStringPointer(cmd, metricsPrefixFlag), + Plugin: flags.FlagToStringSlicePointer(cmd, pluginFlag), + SgwAcl: flags.FlagToStringSlicePointer(cmd, sgwAclFlag), + Syslog: flags.FlagToStringSlicePointer(cmd, syslogFlag), + PlanId: planId, + PlanName: planName, + Version: version, + }, nil +} + +type rabbitMQClient interface { + CreateInstance(ctx context.Context, projectId string) rabbitmq.ApiCreateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*rabbitmq.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient rabbitMQClient) (rabbitmq.ApiCreateInstanceRequest, error) { + req := apiClient.CreateInstance(ctx, model.ProjectId) + + var planId *string + var err error + + offerings, err := apiClient.ListOfferingsExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get RabbitMQ offerings: %w", err) + } + + if model.PlanId == nil { + planId, err = rabbitmqUtils.LoadPlanId(model.PlanName, model.Version, offerings) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + } else { + err := rabbitmqUtils.ValidatePlanId(*model.PlanId, offerings) + if err != nil { + return req, err + } + planId = model.PlanId + } + + var sgwAcl *string + if model.SgwAcl != nil { + sgwAcl = utils.Ptr(strings.Join(*model.SgwAcl, ",")) + } + + req = req.CreateInstancePayload(rabbitmq.CreateInstancePayload{ + InstanceName: model.InstanceName, + Parameters: &rabbitmq.InstanceParameters{ + EnableMonitoring: model.EnableMonitoring, + Graphite: model.Graphite, + MonitoringInstanceId: model.MonitoringInstanceId, + MetricsFrequency: model.MetricsFrequency, + MetricsPrefix: model.MetricsPrefix, + Plugins: model.Plugin, + SgwAcl: sgwAcl, + Syslog: model.Syslog, + }, + PlanId: planId, + }) + return req, nil +} diff --git a/internal/cmd/rabbitmq/instance/create/create_test.go b/internal/cmd/rabbitmq/instance/create/create_test.go new file mode 100644 index 000000000..cf475a8a2 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/create/create_test.go @@ -0,0 +1,484 @@ +package create + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} + +type rabbitMQClientMocked struct { + returnError bool + listOfferingsResp *rabbitmq.ListOfferingsResponse +} + +func (c *rabbitMQClientMocked) CreateInstance(ctx context.Context, projectId string) rabbitmq.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId) +} + +func (c *rabbitMQClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*rabbitmq.ListOfferingsResponse, error) { + if c.returnError { + return nil, fmt.Errorf("list flavors failed") + } + return c.listOfferingsResp, nil +} + +var testProjectId = uuid.NewString() +var testPlanId = uuid.NewString() +var testMonitoringInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceName: utils.Ptr("example-name"), + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugin: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr([]string{"198.51.100.14/24"}), + Syslog: utils.Ptr([]string{"example-syslog"}), + PlanId: utils.Ptr(testPlanId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiCreateInstanceRequest)) rabbitmq.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + request = request.CreateInstancePayload(rabbitmq.CreateInstancePayload{ + InstanceName: utils.Ptr("example-name"), + Parameters: &rabbitmq.InstanceParameters{ + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugins: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr("198.51.100.14/24"), + Syslog: utils.Ptr([]string{"example-syslog"}), + }, + PlanId: utils.Ptr(testPlanId), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + sgwAclValues []string + pluginValues []string + syslogValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with plan name and version", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "6" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "plan-name" + model.Version = "6" + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + planIdFlag: testPlanId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceName: utils.Ptr("example-name"), + PlanId: utils.Ptr(testPlanId), + }, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testPlanId, + instanceNameFlag: "", + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PlanId: utils.Ptr(testPlanId), + InstanceName: utils.Ptr(""), + EnableMonitoring: utils.Ptr(false), + Graphite: utils.Ptr(""), + MetricsFrequency: utils.Ptr(int64(0)), + MetricsPrefix: utils.Ptr(""), + }, + }, + { + 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: "invalid with plan ID, plan name and version", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "6" + }), + isValid: false, + }, + { + description: "invalid with plan ID and plan name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + }), + isValid: false, + }, + { + description: "invalid with plan name only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + delete(flagValues, planIdFlag) + }), + isValid: false, + }, + { + description: "repeated acl flags", + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated acl flag with list value", + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated plugin flags", + flagValues: fixtureFlagValues(), + pluginValues: []string{"example-plugin-1", "example-plugin-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Plugin = utils.Ptr( + append(*model.Plugin, "example-plugin-1", "example-plugin-2"), + ) + }), + }, + { + description: "repeated syslog flags", + flagValues: fixtureFlagValues(), + syslogValues: []string{"example-syslog-1", "example-syslog-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Syslog = utils.Ptr( + append(*model.Syslog, "example-syslog-1", "example-syslog-2"), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.sgwAclValues { + err := cmd.Flags().Set(sgwAclFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) + } + } + + for _, value := range tt.pluginValues { + err := cmd.Flags().Set(pluginFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", pluginFlag, value, err) + } + } + + for _, value := range tt.syslogValues { + err := cmd.Flags().Set(syslogFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiCreateInstanceRequest + getOfferingsFails bool + getOfferingsResp *rabbitmq.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "use plan name and version", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + expectedRequest: fixtureRequest(), + getOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "get offering fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("other-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + isValid: false, + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PlanId: utils.Ptr(testPlanId), + }, + getOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + expectedRequest: testClient.CreateInstance(testCtx, testProjectId). + CreateInstancePayload(rabbitmq.CreateInstancePayload{PlanId: utils.Ptr(testPlanId), Parameters: &rabbitmq.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &rabbitMQClientMocked{ + returnError: tt.getOfferingsFails, + listOfferingsResp: tt.getOfferingsResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/instance/delete/delete.go b/internal/cmd/rabbitmq/instance/delete/delete.go new file mode 100644 index 000000000..1154bfe01 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/delete/delete.go @@ -0,0 +1,114 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/services/rabbitmq/client" + rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes an RabbitMQ instance", + Long: "Deletes an RabbitMQ instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an RabbitMQ instance with ID "xxx"`, + "$ stackit rabbitmq instance delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete instance %s? (This cannot be undone)", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete RabbitMQ instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting instance") + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for RabbitMQ instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s instance %s\n", operationState, instanceLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiDeleteInstanceRequest { + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/rabbitmq/instance/delete/delete_test.go b/internal/cmd/rabbitmq/instance/delete/delete_test.go new file mode 100644 index 000000000..5f4c7f79b --- /dev/null +++ b/internal/cmd/rabbitmq/instance/delete/delete_test.go @@ -0,0 +1,215 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiDeleteInstanceRequest)) rabbitmq.ApiDeleteInstanceRequest { + request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiDeleteInstanceRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/instance/describe/describe.go b/internal/cmd/rabbitmq/instance/describe/describe.go new file mode 100644 index 000000000..c9ca21c43 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/describe/describe.go @@ -0,0 +1,113 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "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/services/rabbitmq/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Shows details of an RabbitMQ instance", + Long: "Shows details of an RabbitMQ instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an RabbitMQ instance with ID "xxx"`, + "$ stackit rabbitmq instance describe xxx"), + examples.NewExample( + `Get details of an RabbitMQ instance with ID "xxx" in a table format`, + "$ stackit rabbitmq instance describe xxx --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read RabbitMQ instance: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instance *rabbitmq.Instance) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *instance.InstanceId) + table.AddSeparator() + table.AddRow("NAME", *instance.Name) + table.AddSeparator() + table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type) + table.AddSeparator() + table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(instance, "", " ") + if err != nil { + return fmt.Errorf("marshal RabbitMQ instance: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/rabbitmq/instance/describe/describe_test.go b/internal/cmd/rabbitmq/instance/describe/describe_test.go new file mode 100644 index 000000000..82625bf0d --- /dev/null +++ b/internal/cmd/rabbitmq/instance/describe/describe_test.go @@ -0,0 +1,215 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiGetInstanceRequest)) rabbitmq.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiGetInstanceRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/instance/instance.go b/internal/cmd/rabbitmq/instance/instance.go new file mode 100644 index 000000000..9e58a61e5 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/instance.go @@ -0,0 +1,33 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for RabbitMQ instances", + Long: "Provides functionality for RabbitMQ instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/rabbitmq/instance/list/list.go b/internal/cmd/rabbitmq/instance/list/list.go new file mode 100644 index 000000000..a44975ce5 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/list/list.go @@ -0,0 +1,142 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all RabbitMQ instances", + Long: "Lists all RabbitMQ instances.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all RabbitMQ instances`, + "$ stackit rabbitmq instance list"), + examples.NewExample( + `List all RabbitMQ instances in JSON format`, + "$ stackit rabbitmq instance list --output-format json"), + examples.NewExample( + `List up to 10 RabbitMQ instances`, + "$ stackit rabbitmq instance list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get RabbitMQ instances: %w", err) + } + instances := *resp.Instances + if len(instances) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No instances found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = instances[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, instances) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiListInstancesRequest { + req := apiClient.ListInstances(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instances []rabbitmq.Instance) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal RabbitMQ instance list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") + for i := range instances { + instance := instances[i] + table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/rabbitmq/instance/list/list_test.go b/internal/cmd/rabbitmq/instance/list/list_test.go new file mode 100644 index 000000000..80556d791 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/list/list_test.go @@ -0,0 +1,185 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiListInstancesRequest)) rabbitmq.ApiListInstancesRequest { + request := testClient.ListInstances(testCtx, testProjectId) + 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: "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", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiListInstancesRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/instance/update/update.go b/internal/cmd/rabbitmq/instance/update/update.go new file mode 100644 index 000000000..c8bcd4087 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/update/update.go @@ -0,0 +1,257 @@ +package update + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + 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/services/rabbitmq/client" + rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + instanceNameFlag = "name" + enableMonitoringFlag = "enable-monitoring" + graphiteFlag = "graphite" + metricsFrequencyFlag = "metrics-frequency" + metricsPrefixFlag = "metrics-prefix" + monitoringInstanceIdFlag = "monitoring-instance-id" + pluginFlag = "plugin" + sgwAclFlag = "acl" + syslogFlag = "syslog" + planIdFlag = "plan-id" + planNameFlag = "plan-name" + versionFlag = "version" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + PlanName string + Version string + + EnableMonitoring *bool + Graphite *string + MetricsFrequency *int64 + MetricsPrefix *string + MonitoringInstanceId *string + Plugin *[]string + SgwAcl *[]string + Syslog *[]string + PlanId *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates an RabbitMQ instance", + Long: "Updates an RabbitMQ instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the plan of an RabbitMQ instance with ID "xxx"`, + "$ stackit rabbitmq instance update xxx --plan-id xxx"), + examples.NewExample( + `Update the range of IPs allowed to access an RabbitMQ instance with ID "xxx"`, + "$ stackit rabbitmq instance update xxx --acl 192.168.1.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update instance %s?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return fmt.Errorf("build RabbitMQ instance update request: %w", err) + } + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("update RabbitMQ instance: %w", err) + } + instanceId := model.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating instance") + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for RabbitMQ instance update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s instance %s\n", operationState, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(enableMonitoringFlag, false, "Enable monitoring") + cmd.Flags().String(graphiteFlag, "", "Graphite host") + cmd.Flags().Int64(metricsFrequencyFlag, 0, "Metrics frequency") + cmd.Flags().String(metricsPrefixFlag, "", "Metrics prefix") + cmd.Flags().Var(flags.UUIDFlag(), monitoringInstanceIdFlag, "Monitoring instance ID") + cmd.Flags().StringSlice(pluginFlag, []string{}, "Plugin") + cmd.Flags().Var(flags.CIDRSliceFlag(), sgwAclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().StringSlice(syslogFlag, []string{}, "Syslog") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(versionFlag, "", "Instance RabbitMQ version") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + enableMonitoring := flags.FlagToBoolPointer(cmd, enableMonitoringFlag) + monitoringInstanceId := flags.FlagToStringPointer(cmd, monitoringInstanceIdFlag) + graphite := flags.FlagToStringPointer(cmd, graphiteFlag) + metricsFrequency := flags.FlagToInt64Pointer(cmd, metricsFrequencyFlag) + metricsPrefix := flags.FlagToStringPointer(cmd, metricsPrefixFlag) + plugin := flags.FlagToStringSlicePointer(cmd, pluginFlag) + sgwAcl := flags.FlagToStringSlicePointer(cmd, sgwAclFlag) + syslog := flags.FlagToStringSlicePointer(cmd, syslogFlag) + planId := flags.FlagToStringPointer(cmd, planIdFlag) + planName := flags.FlagToStringValue(cmd, planNameFlag) + version := flags.FlagToStringValue(cmd, versionFlag) + + if planId != nil && (planName != "" || version != "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + Args: inputArgs, + } + } + + if enableMonitoring == nil && monitoringInstanceId == nil && graphite == nil && + metricsFrequency == nil && metricsPrefix == nil && plugin == nil && + sgwAcl == nil && syslog == nil && planId == nil && + planName == "" && version == "" { + return nil, &cliErr.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + EnableMonitoring: enableMonitoring, + MonitoringInstanceId: monitoringInstanceId, + Graphite: graphite, + MetricsFrequency: metricsFrequency, + MetricsPrefix: metricsPrefix, + Plugin: plugin, + SgwAcl: sgwAcl, + Syslog: syslog, + PlanId: planId, + PlanName: planName, + Version: version, + }, nil +} + +type rabbitMQClient interface { + PartialUpdateInstance(ctx context.Context, projectId, instanceId string) rabbitmq.ApiPartialUpdateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*rabbitmq.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient rabbitMQClient) (rabbitmq.ApiPartialUpdateInstanceRequest, error) { + req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId) + + var planId *string + var err error + + offerings, err := apiClient.ListOfferingsExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get RabbitMQ offerings: %w", err) + } + + if model.PlanId == nil && model.PlanName != "" && model.Version != "" { + planId, err = rabbitmqUtils.LoadPlanId(model.PlanName, model.Version, offerings) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + } else { + // planId is not required for update operation + if model.PlanId != nil { + err := rabbitmqUtils.ValidatePlanId(*model.PlanId, offerings) + if err != nil { + return req, err + } + } + planId = model.PlanId + } + + var sgwAcl *string + if model.SgwAcl != nil { + sgwAcl = utils.Ptr(strings.Join(*model.SgwAcl, ",")) + } + + req = req.PartialUpdateInstancePayload(rabbitmq.PartialUpdateInstancePayload{ + Parameters: &rabbitmq.InstanceParameters{ + EnableMonitoring: model.EnableMonitoring, + Graphite: model.Graphite, + MonitoringInstanceId: model.MonitoringInstanceId, + MetricsFrequency: model.MetricsFrequency, + MetricsPrefix: model.MetricsPrefix, + Plugins: model.Plugin, + SgwAcl: sgwAcl, + Syslog: model.Syslog, + }, + PlanId: planId, + }) + return req, nil +} diff --git a/internal/cmd/rabbitmq/instance/update/update_test.go b/internal/cmd/rabbitmq/instance/update/update_test.go new file mode 100644 index 000000000..a617cb4c0 --- /dev/null +++ b/internal/cmd/rabbitmq/instance/update/update_test.go @@ -0,0 +1,485 @@ +package update + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} + +type rabbitMQClientMocked struct { + returnError bool + listOfferingsResp *rabbitmq.ListOfferingsResponse +} + +func (c *rabbitMQClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) rabbitmq.ApiPartialUpdateInstanceRequest { + return testClient.PartialUpdateInstance(ctx, projectId, instanceId) +} + +func (c *rabbitMQClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*rabbitmq.ListOfferingsResponse, error) { + if c.returnError { + return nil, fmt.Errorf("list flavors failed") + } + return c.listOfferingsResp, nil +} + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testPlanId = uuid.NewString() + testMonitoringInstanceId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugin: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr([]string{"198.51.100.14/24"}), + Syslog: utils.Ptr([]string{"example-syslog"}), + PlanId: utils.Ptr(testPlanId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiPartialUpdateInstanceRequest)) rabbitmq.ApiPartialUpdateInstanceRequest { + request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId) + request = request.PartialUpdateInstancePayload(rabbitmq.PartialUpdateInstancePayload{ + Parameters: &rabbitmq.InstanceParameters{ + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugins: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr("198.51.100.14/24"), + Syslog: utils.Ptr([]string{"example-syslog"}), + }, + PlanId: utils.Ptr(testPlanId), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + sgwAclValues []string + pluginValues []string + syslogValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required flags only (no values to update)", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + }, + }, + { + description: "zero values", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testPlanId, + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + PlanId: utils.Ptr(testPlanId), + EnableMonitoring: utils.Ptr(false), + Graphite: utils.Ptr(""), + MetricsFrequency: utils.Ptr(int64(0)), + MetricsPrefix: utils.Ptr(""), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "repeated acl flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated acl flag with list value", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated plugin flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + pluginValues: []string{"example-plugin-1", "example-plugin-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Plugin = utils.Ptr( + append(*model.Plugin, "example-plugin-1", "example-plugin-2"), + ) + }), + }, + { + description: "repeated syslog flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + syslogValues: []string{"example-syslog-1", "example-syslog-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Syslog = utils.Ptr( + append(*model.Syslog, "example-syslog-1", "example-syslog-2"), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.sgwAclValues { + err := cmd.Flags().Set(sgwAclFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) + } + } + + for _, value := range tt.pluginValues { + err := cmd.Flags().Set(pluginFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", pluginFlag, value, err) + } + } + + for _, value := range tt.syslogValues { + err := cmd.Flags().Set(syslogFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiPartialUpdateInstanceRequest + getOfferingsFails bool + listOfferingsResp *rabbitmq.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + listOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "use plan name and version", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + expectedRequest: fixtureRequest(), + listOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "get offering fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + listOfferingsResp: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr("other-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + isValid: false, + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + }, + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + PartialUpdateInstancePayload(rabbitmq.PartialUpdateInstancePayload{Parameters: &rabbitmq.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &rabbitMQClientMocked{ + returnError: tt.getOfferingsFails, + listOfferingsResp: tt.listOfferingsResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/plans/plans.go b/internal/cmd/rabbitmq/plans/plans.go new file mode 100644 index 000000000..77279fe5a --- /dev/null +++ b/internal/cmd/rabbitmq/plans/plans.go @@ -0,0 +1,147 @@ +package plans + +import ( + "context" + "encoding/json" + "fmt" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all RabbitMQ service plans", + Long: "Lists all RabbitMQ service plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all RabbitMQ service plans`, + "$ stackit rabbitmq plans"), + examples.NewExample( + `List all RabbitMQ service plans in JSON format`, + "$ stackit rabbitmq plans --output-format json"), + examples.NewExample( + `List up to 10 RabbitMQ service plans`, + "$ stackit rabbitmq plans --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get RabbitMQ service plans: %w", err) + } + plans := *resp.Offerings + if len(plans) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No plans found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(plans) > int(*model.Limit) { + plans = plans[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, plans) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.APIClient) rabbitmq.ApiListOfferingsRequest { + req := apiClient.ListOfferings(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, plans []rabbitmq.Offering) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(plans, "", " ") + if err != nil { + return fmt.Errorf("marshal RabbitMQ plans: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + for i := range plans { + o := plans[i] + for j := range *o.Plans { + p := (*o.Plans)[j] + table.AddRow(*o.Name, *p.Id, *p.Name, *p.Description) + } + table.AddSeparator() + } + table.EnableAutoMergeOnColumns(1) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/rabbitmq/plans/plans_test.go b/internal/cmd/rabbitmq/plans/plans_test.go new file mode 100644 index 000000000..9829fe35d --- /dev/null +++ b/internal/cmd/rabbitmq/plans/plans_test.go @@ -0,0 +1,185 @@ +package plans + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &rabbitmq.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *rabbitmq.ApiListOfferingsRequest)) rabbitmq.ApiListOfferingsRequest { + request := testClient.ListOfferings(testCtx, testProjectId) + 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: "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", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest rabbitmq.ApiListOfferingsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/rabbitmq/rabbitmq.go b/internal/cmd/rabbitmq/rabbitmq.go new file mode 100644 index 000000000..916e9cc9f --- /dev/null +++ b/internal/cmd/rabbitmq/rabbitmq.go @@ -0,0 +1,29 @@ +package rabbitmq + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/plans" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rabbitmq", + Short: "Provides functionality for RabbitMQ", + Long: "Provides functionality for RabbitMQ.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(instance.NewCmd()) + cmd.AddCommand(plans.NewCmd()) + cmd.AddCommand(credentials.NewCmd()) +} diff --git a/internal/cmd/redis/credentials/create/create.go b/internal/cmd/redis/credentials/create/create.go new file mode 100644 index 000000000..19ce067c1 --- /dev/null +++ b/internal/cmd/redis/credentials/create/create.go @@ -0,0 +1,119 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/services/redis/client" + redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + instanceIdFlag = "instance-id" + hidePasswordFlag = "hide-password" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + HidePassword bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates credentials for a Redis instance", + Long: "Creates credentials (username and password) for a Redis instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create credentials for a Redis instance`, + "$ stackit redis credentials create --instance-id xxx"), + examples.NewExample( + `Create credentials for a Redis instance and hide the password in the output`, + "$ stackit redis credentials create --instance-id xxx --hide-password"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %s?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Redis credentials: %w", err) + } + + cmd.Printf("Created credentials for instance %s. Credentials ID: %s\n\n", instanceLabel, *resp.Id) + cmd.Printf("Username: %s\n", *resp.Raw.Credentials.Username) + if model.HidePassword { + cmd.Printf("Password: \n") + } else { + cmd.Printf("Password: %s\n", *resp.Raw.Credentials.Password) + } + cmd.Printf("Host: %s\n", *resp.Raw.Credentials.Host) + cmd.Printf("Port: %d\n", *resp.Raw.Credentials.Port) + cmd.Printf("URI: %s\n", *resp.Uri) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Bool(hidePasswordFlag, false, "Hide password in output") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + HidePassword: flags.FlagToBoolValue(cmd, hidePasswordFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiCreateCredentialsRequest { + req := apiClient.CreateCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/redis/credentials/create/create_test.go b/internal/cmd/redis/credentials/create/create_test.go new file mode 100644 index 000000000..aea102622 --- /dev/null +++ b/internal/cmd/redis/credentials/create/create_test.go @@ -0,0 +1,189 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiCreateCredentialsRequest)) redis.ApiCreateCredentialsRequest { + request := testClient.CreateCredentials(testCtx, testProjectId, testInstanceId) + 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: "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: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiCreateCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/credentials/credentials.go b/internal/cmd/redis/credentials/credentials.go new file mode 100644 index 000000000..36eac3341 --- /dev/null +++ b/internal/cmd/redis/credentials/credentials.go @@ -0,0 +1,31 @@ +package credentials + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "credentials", + Short: "Provides functionality for Redis credentials", + Long: "Provides functionality for Redis credentials.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) +} diff --git a/internal/cmd/redis/credentials/delete/delete.go b/internal/cmd/redis/credentials/delete/delete.go new file mode 100644 index 000000000..6e8f8b319 --- /dev/null +++ b/internal/cmd/redis/credentials/delete/delete.go @@ -0,0 +1,115 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/services/redis/client" + redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + credentialsIdArg = "CREDENTIALS_ID" //nolint:gosec // linter false positive + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + CredentialsId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", credentialsIdArg), + Short: "Deletes credentials of a Redis instance", + Long: "Deletes credentials of a Redis instance.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete credentials with ID "xxx" of Redis instance with ID "yyy"`, + "$ stackit redis credentials delete xxx --instance-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + credentialsLabel, err := redisUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) + if err != nil { + credentialsLabel = model.CredentialsId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %s? (This cannot be undone)", credentialsLabel, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Redis credentials: %w", err) + } + + cmd.Printf("Deleted credentials %s of instance %s\n", credentialsLabel, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + CredentialsId: credentialsId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiDeleteCredentialsRequest { + req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} diff --git a/internal/cmd/redis/credentials/delete/delete_test.go b/internal/cmd/redis/credentials/delete/delete_test.go new file mode 100644 index 000000000..42a6730bf --- /dev/null +++ b/internal/cmd/redis/credentials/delete/delete_test.go @@ -0,0 +1,242 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testCredentialsId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + CredentialsId: testCredentialsId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiDeleteCredentialsRequest)) redis.ApiDeleteCredentialsRequest { + request := testClient.DeleteCredentials(testCtx, testProjectId, testInstanceId, testCredentialsId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "credentials id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "credentials id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiDeleteCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/credentials/describe/describe.go b/internal/cmd/redis/credentials/describe/describe.go new file mode 100644 index 000000000..5b44b7c84 --- /dev/null +++ b/internal/cmd/redis/credentials/describe/describe.go @@ -0,0 +1,127 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "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/services/redis/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + credentialsIdArg = "CREDENTIALS_ID" //nolint:gosec // linter false positive + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + CredentialsId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", credentialsIdArg), + Short: "Shows details of credentials of a Redis instance", + Long: "Shows details of credentials of a Redis instance. The password will be shown in plain text in the output.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of credentials of a Redis instance with ID "xxx" from instance with ID "yyy"`, + "$ stackit redis credentials describe xxx --instance-id yyy"), + examples.NewExample( + `Get details of credentials of a Redis instance with ID "xxx" from instance with ID "yyy" in a table format`, + "$ stackit redis credentials describe xxx --instance-id yyy --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe Redis credentials: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + CredentialsId: credentialsId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiGetCredentialsRequest { + req := apiClient.GetCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials *redis.CredentialsResponse) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *credentials.Id) + table.AddSeparator() + table.AddRow("USERNAME", *credentials.Raw.Credentials.Username) + table.AddSeparator() + table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password) + table.AddSeparator() + table.AddRow("URI", *credentials.Raw.Credentials.Uri) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal Redis credentials: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/redis/credentials/describe/describe_test.go b/internal/cmd/redis/credentials/describe/describe_test.go new file mode 100644 index 000000000..d53abcd65 --- /dev/null +++ b/internal/cmd/redis/credentials/describe/describe_test.go @@ -0,0 +1,242 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testCredentialsId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + CredentialsId: testCredentialsId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiGetCredentialsRequest)) redis.ApiGetCredentialsRequest { + request := testClient.GetCredentials(testCtx, testProjectId, testInstanceId, testCredentialsId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "credentials id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "credentials id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiGetCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/credentials/list/list.go b/internal/cmd/redis/credentials/list/list.go new file mode 100644 index 000000000..4ab570f48 --- /dev/null +++ b/internal/cmd/redis/credentials/list/list.go @@ -0,0 +1,147 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "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/services/redis/client" + redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + instanceIdFlag = "instance-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all credentials' IDs for a Redis instance", + Long: "Lists all credentials' IDs for a Redis instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all credentials' IDs for a Redis instance`, + "$ stackit redis credentials list --instance-id xxx"), + examples.NewExample( + `List all credentials' IDs for a Redis instance in JSON format`, + "$ stackit redis credentials list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 credentials' IDs for a Redis instance`, + "$ stackit redis credentials list --instance-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Redis credentialss: %w", err) + } + credentials := *resp.CredentialsList + if len(credentials) == 0 { + instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + cmd.Printf("No credentials found for instance %s\n", instanceLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(credentials) > int(*model.Limit) { + credentials = credentials[:*model.Limit] + } + return outputResult(cmd, model.OutputFormat, credentials) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials []redis.CredentialsListItem) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal Redis credentials list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID") + for i := range credentials { + c := credentials[i] + table.AddRow(*c.Id) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/redis/credentials/list/list_test.go b/internal/cmd/redis/credentials/list/list_test.go new file mode 100644 index 000000000..5371e72fd --- /dev/null +++ b/internal/cmd/redis/credentials/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiListCredentialsRequest)) redis.ApiListCredentialsRequest { + request := testClient.ListCredentials(testCtx, testProjectId, testInstanceId) + 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: "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: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiListCredentialsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/instance/create/create.go b/internal/cmd/redis/instance/create/create.go new file mode 100644 index 000000000..4757cf90c --- /dev/null +++ b/internal/cmd/redis/instance/create/create.go @@ -0,0 +1,248 @@ +package create + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + 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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client" + redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-sdk-go/services/redis/wait" +) + +const ( + instanceNameFlag = "name" + enableMonitoringFlag = "enable-monitoring" + graphiteFlag = "graphite" + metricsFrequencyFlag = "metrics-frequency" + metricsPrefixFlag = "metrics-prefix" + monitoringInstanceIdFlag = "monitoring-instance-id" + pluginFlag = "plugin" + sgwAclFlag = "acl" + syslogFlag = "syslog" + planIdFlag = "plan-id" + planNameFlag = "plan-name" + versionFlag = "version" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlanName string + Version string + + InstanceName *string + EnableMonitoring *bool + Graphite *string + MetricsFrequency *int64 + MetricsPrefix *string + MonitoringInstanceId *string + Plugin *[]string + SgwAcl *[]string + Syslog *[]string + PlanId *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Redis instance", + Long: "Creates a Redis instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a Redis instance with name "my-instance" and specify plan by name and version`, + "$ stackit redis instance create --name my-instance --plan-name stackit-redis-1.2.10-replica --version 6"), + examples.NewExample( + `Create a Redis instance with name "my-instance" and specify plan by ID`, + "$ stackit redis instance create --name my-instance --plan-id xxx"), + examples.NewExample( + `Create a Redis instance with name "my-instance" and specify IP range which is allowed to access it`, + "$ stackit redis instance create --name my-instance --plan-id xxx --acl 192.168.1.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a Redis instance for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return fmt.Errorf("build Redis instance creation request: %w", err) + } + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Redis instance: %w", err) + } + instanceId := *resp.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating instance") + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for Redis instance creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s instance for project %s. Instance ID: %s\n", operationState, projectLabel, instanceId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") + cmd.Flags().Bool(enableMonitoringFlag, false, "Enable monitoring") + cmd.Flags().String(graphiteFlag, "", "Graphite host") + cmd.Flags().Int64(metricsFrequencyFlag, 0, "Metrics frequency") + cmd.Flags().String(metricsPrefixFlag, "", "Metrics prefix") + cmd.Flags().Var(flags.UUIDFlag(), monitoringInstanceIdFlag, "Monitoring instance ID") + cmd.Flags().StringSlice(pluginFlag, []string{}, "Plugin") + cmd.Flags().Var(flags.CIDRSliceFlag(), sgwAclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().StringSlice(syslogFlag, []string{}, "Syslog") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(versionFlag, "", "Instance Redis version") + + err := flags.MarkFlagsRequired(cmd, instanceNameFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + planId := flags.FlagToStringPointer(cmd, planIdFlag) + planName := flags.FlagToStringValue(cmd, planNameFlag) + version := flags.FlagToStringValue(cmd, versionFlag) + + if planId == nil && (planName == "" || version == "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + if planId != nil && (planName != "" || version != "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceName: flags.FlagToStringPointer(cmd, instanceNameFlag), + EnableMonitoring: flags.FlagToBoolPointer(cmd, enableMonitoringFlag), + MonitoringInstanceId: flags.FlagToStringPointer(cmd, monitoringInstanceIdFlag), + Graphite: flags.FlagToStringPointer(cmd, graphiteFlag), + MetricsFrequency: flags.FlagToInt64Pointer(cmd, metricsFrequencyFlag), + MetricsPrefix: flags.FlagToStringPointer(cmd, metricsPrefixFlag), + Plugin: flags.FlagToStringSlicePointer(cmd, pluginFlag), + SgwAcl: flags.FlagToStringSlicePointer(cmd, sgwAclFlag), + Syslog: flags.FlagToStringSlicePointer(cmd, syslogFlag), + PlanId: planId, + PlanName: planName, + Version: version, + }, nil +} + +type redisClient interface { + CreateInstance(ctx context.Context, projectId string) redis.ApiCreateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*redis.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient redisClient) (redis.ApiCreateInstanceRequest, error) { + req := apiClient.CreateInstance(ctx, model.ProjectId) + + var planId *string + var err error + + offerings, err := apiClient.ListOfferingsExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get Redis offerings: %w", err) + } + + if model.PlanId == nil { + planId, err = redisUtils.LoadPlanId(model.PlanName, model.Version, offerings) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + } else { + err := redisUtils.ValidatePlanId(*model.PlanId, offerings) + if err != nil { + return req, err + } + planId = model.PlanId + } + + var sgwAcl *string + if model.SgwAcl != nil { + sgwAcl = utils.Ptr(strings.Join(*model.SgwAcl, ",")) + } + + req = req.CreateInstancePayload(redis.CreateInstancePayload{ + InstanceName: model.InstanceName, + Parameters: &redis.InstanceParameters{ + EnableMonitoring: model.EnableMonitoring, + Graphite: model.Graphite, + MonitoringInstanceId: model.MonitoringInstanceId, + MetricsFrequency: model.MetricsFrequency, + MetricsPrefix: model.MetricsPrefix, + Plugins: model.Plugin, + SgwAcl: sgwAcl, + Syslog: model.Syslog, + }, + PlanId: planId, + }) + return req, nil +} diff --git a/internal/cmd/redis/instance/create/create_test.go b/internal/cmd/redis/instance/create/create_test.go new file mode 100644 index 000000000..ed9e8bf55 --- /dev/null +++ b/internal/cmd/redis/instance/create/create_test.go @@ -0,0 +1,484 @@ +package create + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} + +type redisClientMocked struct { + returnError bool + listOfferingsResp *redis.ListOfferingsResponse +} + +func (c *redisClientMocked) CreateInstance(ctx context.Context, projectId string) redis.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId) +} + +func (c *redisClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*redis.ListOfferingsResponse, error) { + if c.returnError { + return nil, fmt.Errorf("list flavors failed") + } + return c.listOfferingsResp, nil +} + +var testProjectId = uuid.NewString() +var testPlanId = uuid.NewString() +var testMonitoringInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceName: utils.Ptr("example-name"), + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugin: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr([]string{"198.51.100.14/24"}), + Syslog: utils.Ptr([]string{"example-syslog"}), + PlanId: utils.Ptr(testPlanId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiCreateInstanceRequest)) redis.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + request = request.CreateInstancePayload(redis.CreateInstancePayload{ + InstanceName: utils.Ptr("example-name"), + Parameters: &redis.InstanceParameters{ + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugins: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr("198.51.100.14/24"), + Syslog: utils.Ptr([]string{"example-syslog"}), + }, + PlanId: utils.Ptr(testPlanId), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + sgwAclValues []string + pluginValues []string + syslogValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with plan name and version", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "6" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "plan-name" + model.Version = "6" + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + planIdFlag: testPlanId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceName: utils.Ptr("example-name"), + PlanId: utils.Ptr(testPlanId), + }, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testPlanId, + instanceNameFlag: "", + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PlanId: utils.Ptr(testPlanId), + InstanceName: utils.Ptr(""), + EnableMonitoring: utils.Ptr(false), + Graphite: utils.Ptr(""), + MetricsFrequency: utils.Ptr(int64(0)), + MetricsPrefix: utils.Ptr(""), + }, + }, + { + 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: "invalid with plan ID, plan name and version", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "6" + }), + isValid: false, + }, + { + description: "invalid with plan ID and plan name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + }), + isValid: false, + }, + { + description: "invalid with plan name only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + delete(flagValues, planIdFlag) + }), + isValid: false, + }, + { + description: "repeated acl flags", + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated acl flag with list value", + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated plugin flags", + flagValues: fixtureFlagValues(), + pluginValues: []string{"example-plugin-1", "example-plugin-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Plugin = utils.Ptr( + append(*model.Plugin, "example-plugin-1", "example-plugin-2"), + ) + }), + }, + { + description: "repeated syslog flags", + flagValues: fixtureFlagValues(), + syslogValues: []string{"example-syslog-1", "example-syslog-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Syslog = utils.Ptr( + append(*model.Syslog, "example-syslog-1", "example-syslog-2"), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.sgwAclValues { + err := cmd.Flags().Set(sgwAclFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) + } + } + + for _, value := range tt.pluginValues { + err := cmd.Flags().Set(pluginFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", pluginFlag, value, err) + } + } + + for _, value := range tt.syslogValues { + err := cmd.Flags().Set(syslogFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiCreateInstanceRequest + getOfferingsFails bool + getOfferingsResp *redis.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "use plan name and version", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + expectedRequest: fixtureRequest(), + getOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "get offering fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("other-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + isValid: false, + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PlanId: utils.Ptr(testPlanId), + }, + getOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + expectedRequest: testClient.CreateInstance(testCtx, testProjectId). + CreateInstancePayload(redis.CreateInstancePayload{PlanId: utils.Ptr(testPlanId), Parameters: &redis.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &redisClientMocked{ + returnError: tt.getOfferingsFails, + listOfferingsResp: tt.getOfferingsResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/instance/delete/delete.go b/internal/cmd/redis/instance/delete/delete.go new file mode 100644 index 000000000..843ef2295 --- /dev/null +++ b/internal/cmd/redis/instance/delete/delete.go @@ -0,0 +1,114 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/services/redis/client" + redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-sdk-go/services/redis/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes a Redis instance", + Long: "Deletes a Redis instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a Redis instance with ID "xxx"`, + "$ stackit redis instance delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete instance %s? (This cannot be undone)", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Redis instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting instance") + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for Redis instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s instance %s\n", operationState, instanceLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiDeleteInstanceRequest { + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/redis/instance/delete/delete_test.go b/internal/cmd/redis/instance/delete/delete_test.go new file mode 100644 index 000000000..41a664543 --- /dev/null +++ b/internal/cmd/redis/instance/delete/delete_test.go @@ -0,0 +1,215 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiDeleteInstanceRequest)) redis.ApiDeleteInstanceRequest { + request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiDeleteInstanceRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/instance/describe/describe.go b/internal/cmd/redis/instance/describe/describe.go new file mode 100644 index 000000000..463f40c38 --- /dev/null +++ b/internal/cmd/redis/instance/describe/describe.go @@ -0,0 +1,113 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "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/services/redis/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Shows details of a Redis instance", + Long: "Shows details of a Redis instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a Redis instance with ID "xxx"`, + "$ stackit redis instance describe xxx"), + examples.NewExample( + `Get details of a Redis instance with ID "xxx" in a table format`, + "$ stackit redis instance describe xxx --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read Redis instance: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instance *redis.Instance) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *instance.InstanceId) + table.AddSeparator() + table.AddRow("NAME", *instance.Name) + table.AddSeparator() + table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type) + table.AddSeparator() + table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(instance, "", " ") + if err != nil { + return fmt.Errorf("marshal Redis instance: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/redis/instance/describe/describe_test.go b/internal/cmd/redis/instance/describe/describe_test.go new file mode 100644 index 000000000..b8a4af50f --- /dev/null +++ b/internal/cmd/redis/instance/describe/describe_test.go @@ -0,0 +1,215 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiGetInstanceRequest)) redis.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + 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: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiGetInstanceRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/instance/instance.go b/internal/cmd/redis/instance/instance.go new file mode 100644 index 000000000..920351de2 --- /dev/null +++ b/internal/cmd/redis/instance/instance.go @@ -0,0 +1,33 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for Redis instances", + Long: "Provides functionality for Redis instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/redis/instance/list/list.go b/internal/cmd/redis/instance/list/list.go new file mode 100644 index 000000000..589538cc9 --- /dev/null +++ b/internal/cmd/redis/instance/list/list.go @@ -0,0 +1,142 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Redis instances", + Long: "Lists all Redis instances.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Redis instances`, + "$ stackit redis instance list"), + examples.NewExample( + `List all Redis instances in JSON format`, + "$ stackit redis instance list --output-format json"), + examples.NewExample( + `List up to 10 Redis instances`, + "$ stackit redis instance list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Redis instances: %w", err) + } + instances := *resp.Instances + if len(instances) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No instances found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = instances[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, instances) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiListInstancesRequest { + req := apiClient.ListInstances(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instances []redis.Instance) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal Redis instance list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") + for i := range instances { + instance := instances[i] + table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/redis/instance/list/list_test.go b/internal/cmd/redis/instance/list/list_test.go new file mode 100644 index 000000000..7788ac864 --- /dev/null +++ b/internal/cmd/redis/instance/list/list_test.go @@ -0,0 +1,185 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiListInstancesRequest)) redis.ApiListInstancesRequest { + request := testClient.ListInstances(testCtx, testProjectId) + 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: "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", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiListInstancesRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/instance/update/update.go b/internal/cmd/redis/instance/update/update.go new file mode 100644 index 000000000..a499e1a3e --- /dev/null +++ b/internal/cmd/redis/instance/update/update.go @@ -0,0 +1,257 @@ +package update + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + 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/services/redis/client" + redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-sdk-go/services/redis/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + instanceNameFlag = "name" + enableMonitoringFlag = "enable-monitoring" + graphiteFlag = "graphite" + metricsFrequencyFlag = "metrics-frequency" + metricsPrefixFlag = "metrics-prefix" + monitoringInstanceIdFlag = "monitoring-instance-id" + pluginFlag = "plugin" + sgwAclFlag = "acl" + syslogFlag = "syslog" + planIdFlag = "plan-id" + planNameFlag = "plan-name" + versionFlag = "version" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + PlanName string + Version string + + EnableMonitoring *bool + Graphite *string + MetricsFrequency *int64 + MetricsPrefix *string + MonitoringInstanceId *string + Plugin *[]string + SgwAcl *[]string + Syslog *[]string + PlanId *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates a Redis instance", + Long: "Updates a Redis instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the plan of a Redis instance with ID "xxx"`, + "$ stackit redis instance update xxx --plan-id xxx"), + examples.NewExample( + `Update the range of IPs allowed to access a Redis instance with ID "xxx"`, + "$ stackit redis instance update xxx --acl 192.168.1.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update instance %s?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return fmt.Errorf("build Redis instance update request: %w", err) + } + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("update Redis instance: %w", err) + } + instanceId := model.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating instance") + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for Redis instance update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s instance %s\n", operationState, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(enableMonitoringFlag, false, "Enable monitoring") + cmd.Flags().String(graphiteFlag, "", "Graphite host") + cmd.Flags().Int64(metricsFrequencyFlag, 0, "Metrics frequency") + cmd.Flags().String(metricsPrefixFlag, "", "Metrics prefix") + cmd.Flags().Var(flags.UUIDFlag(), monitoringInstanceIdFlag, "Monitoring instance ID") + cmd.Flags().StringSlice(pluginFlag, []string{}, "Plugin") + cmd.Flags().Var(flags.CIDRSliceFlag(), sgwAclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().StringSlice(syslogFlag, []string{}, "Syslog") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(versionFlag, "", "Instance Redis version") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + enableMonitoring := flags.FlagToBoolPointer(cmd, enableMonitoringFlag) + monitoringInstanceId := flags.FlagToStringPointer(cmd, monitoringInstanceIdFlag) + graphite := flags.FlagToStringPointer(cmd, graphiteFlag) + metricsFrequency := flags.FlagToInt64Pointer(cmd, metricsFrequencyFlag) + metricsPrefix := flags.FlagToStringPointer(cmd, metricsPrefixFlag) + plugin := flags.FlagToStringSlicePointer(cmd, pluginFlag) + sgwAcl := flags.FlagToStringSlicePointer(cmd, sgwAclFlag) + syslog := flags.FlagToStringSlicePointer(cmd, syslogFlag) + planId := flags.FlagToStringPointer(cmd, planIdFlag) + planName := flags.FlagToStringValue(cmd, planNameFlag) + version := flags.FlagToStringValue(cmd, versionFlag) + + if planId != nil && (planName != "" || version != "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + Args: inputArgs, + } + } + + if enableMonitoring == nil && monitoringInstanceId == nil && graphite == nil && + metricsFrequency == nil && metricsPrefix == nil && plugin == nil && + sgwAcl == nil && syslog == nil && planId == nil && + planName == "" && version == "" { + return nil, &cliErr.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + EnableMonitoring: enableMonitoring, + MonitoringInstanceId: monitoringInstanceId, + Graphite: graphite, + MetricsFrequency: metricsFrequency, + MetricsPrefix: metricsPrefix, + Plugin: plugin, + SgwAcl: sgwAcl, + Syslog: syslog, + PlanId: planId, + PlanName: planName, + Version: version, + }, nil +} + +type redisClient interface { + PartialUpdateInstance(ctx context.Context, projectId, instanceId string) redis.ApiPartialUpdateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*redis.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient redisClient) (redis.ApiPartialUpdateInstanceRequest, error) { + req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId) + + var planId *string + var err error + + offerings, err := apiClient.ListOfferingsExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get Redis offerings: %w", err) + } + + if model.PlanId == nil && model.PlanName != "" && model.Version != "" { + planId, err = redisUtils.LoadPlanId(model.PlanName, model.Version, offerings) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + } else { + // planId is not required for update operation + if model.PlanId != nil { + err := redisUtils.ValidatePlanId(*model.PlanId, offerings) + if err != nil { + return req, err + } + } + planId = model.PlanId + } + + var sgwAcl *string + if model.SgwAcl != nil { + sgwAcl = utils.Ptr(strings.Join(*model.SgwAcl, ",")) + } + + req = req.PartialUpdateInstancePayload(redis.PartialUpdateInstancePayload{ + Parameters: &redis.InstanceParameters{ + EnableMonitoring: model.EnableMonitoring, + Graphite: model.Graphite, + MonitoringInstanceId: model.MonitoringInstanceId, + MetricsFrequency: model.MetricsFrequency, + MetricsPrefix: model.MetricsPrefix, + Plugins: model.Plugin, + SgwAcl: sgwAcl, + Syslog: model.Syslog, + }, + PlanId: planId, + }) + return req, nil +} diff --git a/internal/cmd/redis/instance/update/update_test.go b/internal/cmd/redis/instance/update/update_test.go new file mode 100644 index 000000000..e31a87b8e --- /dev/null +++ b/internal/cmd/redis/instance/update/update_test.go @@ -0,0 +1,485 @@ +package update + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} + +type redisClientMocked struct { + returnError bool + listOfferingsResp *redis.ListOfferingsResponse +} + +func (c *redisClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) redis.ApiPartialUpdateInstanceRequest { + return testClient.PartialUpdateInstance(ctx, projectId, instanceId) +} + +func (c *redisClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*redis.ListOfferingsResponse, error) { + if c.returnError { + return nil, fmt.Errorf("list flavors failed") + } + return c.listOfferingsResp, nil +} + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testPlanId = uuid.NewString() + testMonitoringInstanceId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugin: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr([]string{"198.51.100.14/24"}), + Syslog: utils.Ptr([]string{"example-syslog"}), + PlanId: utils.Ptr(testPlanId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiPartialUpdateInstanceRequest)) redis.ApiPartialUpdateInstanceRequest { + request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId) + request = request.PartialUpdateInstancePayload(redis.PartialUpdateInstancePayload{ + Parameters: &redis.InstanceParameters{ + EnableMonitoring: utils.Ptr(true), + Graphite: utils.Ptr("example-graphite"), + MetricsFrequency: utils.Ptr(int64(100)), + MetricsPrefix: utils.Ptr("example-prefix"), + MonitoringInstanceId: utils.Ptr(testMonitoringInstanceId), + Plugins: utils.Ptr([]string{"example-plugin"}), + SgwAcl: utils.Ptr("198.51.100.14/24"), + Syslog: utils.Ptr([]string{"example-syslog"}), + }, + PlanId: utils.Ptr(testPlanId), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + sgwAclValues []string + pluginValues []string + syslogValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required flags only (no values to update)", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + }, + }, + { + description: "zero values", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testPlanId, + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + PlanId: utils.Ptr(testPlanId), + EnableMonitoring: utils.Ptr(false), + Graphite: utils.Ptr(""), + MetricsFrequency: utils.Ptr(int64(0)), + MetricsPrefix: utils.Ptr(""), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "repeated acl flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated acl flag with list value", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = utils.Ptr( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated plugin flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + pluginValues: []string{"example-plugin-1", "example-plugin-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Plugin = utils.Ptr( + append(*model.Plugin, "example-plugin-1", "example-plugin-2"), + ) + }), + }, + { + description: "repeated syslog flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + syslogValues: []string{"example-syslog-1", "example-syslog-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Syslog = utils.Ptr( + append(*model.Syslog, "example-syslog-1", "example-syslog-2"), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.sgwAclValues { + err := cmd.Flags().Set(sgwAclFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) + } + } + + for _, value := range tt.pluginValues { + err := cmd.Flags().Set(pluginFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", pluginFlag, value, err) + } + } + + for _, value := range tt.syslogValues { + err := cmd.Flags().Set(syslogFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiPartialUpdateInstanceRequest + getOfferingsFails bool + listOfferingsResp *redis.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + listOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "use plan name and version", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + expectedRequest: fixtureRequest(), + listOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + }, + { + description: "get offering fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + listOfferingsResp: &redis.ListOfferingsResponse{ + Offerings: &[]redis.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]redis.Plan{ + { + Name: utils.Ptr("other-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + isValid: false, + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + }, + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + PartialUpdateInstancePayload(redis.PartialUpdateInstancePayload{Parameters: &redis.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &redisClientMocked{ + returnError: tt.getOfferingsFails, + listOfferingsResp: tt.listOfferingsResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/plans/plans.go b/internal/cmd/redis/plans/plans.go new file mode 100644 index 000000000..738d3e5d5 --- /dev/null +++ b/internal/cmd/redis/plans/plans.go @@ -0,0 +1,147 @@ +package plans + +import ( + "context" + "encoding/json" + "fmt" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all Redis service plans", + Long: "Lists all Redis service plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Redis service plans`, + "$ stackit redis plans"), + examples.NewExample( + `List all Redis service plans in JSON format`, + "$ stackit redis plans --output-format json"), + examples.NewExample( + `List up to 10 Redis service plans`, + "$ stackit redis plans --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Redis service plans: %w", err) + } + plans := *resp.Offerings + if len(plans) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No plans found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(plans) > int(*model.Limit) { + plans = plans[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, plans) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APIClient) redis.ApiListOfferingsRequest { + req := apiClient.ListOfferings(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, plans []redis.Offering) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(plans, "", " ") + if err != nil { + return fmt.Errorf("marshal Redis plans: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + for i := range plans { + o := plans[i] + for j := range *o.Plans { + p := (*o.Plans)[j] + table.AddRow(*o.Name, *p.Id, *p.Name, *p.Description) + } + table.AddSeparator() + } + table.EnableAutoMergeOnColumns(1) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/redis/plans/plans_test.go b/internal/cmd/redis/plans/plans_test.go new file mode 100644 index 000000000..ff0098767 --- /dev/null +++ b/internal/cmd/redis/plans/plans_test.go @@ -0,0 +1,185 @@ +package plans + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &redis.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *redis.ApiListOfferingsRequest)) redis.ApiListOfferingsRequest { + request := testClient.ListOfferings(testCtx, testProjectId) + 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: "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", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest redis.ApiListOfferingsRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/redis/redis.go b/internal/cmd/redis/redis.go new file mode 100644 index 000000000..25e306217 --- /dev/null +++ b/internal/cmd/redis/redis.go @@ -0,0 +1,29 @@ +package redis + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis/plans" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "redis", + Short: "Provides functionality for Redis", + Long: "Provides functionality for Redis.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(instance.NewCmd()) + cmd.AddCommand(plans.NewCmd()) + cmd.AddCommand(credentials.NewCmd()) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 46002bfd7..468eaf658 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -15,6 +15,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/organization" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex" "github.com/stackitcloud/stackit-cli/internal/cmd/project" + "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/cmd/redis" serviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/service-account" "github.com/stackitcloud/stackit-cli/internal/cmd/ske" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -87,6 +89,8 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(organization.NewCmd()) cmd.AddCommand(postgresflex.NewCmd()) cmd.AddCommand(project.NewCmd()) + cmd.AddCommand(rabbitmq.NewCmd()) + cmd.AddCommand(redis.NewCmd()) cmd.AddCommand(serviceaccount.NewCmd()) cmd.AddCommand(ske.NewCmd()) } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index ad605d16a..e9426d9fb 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -21,6 +21,8 @@ const ( MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint" OpenSearchCustomEndpointKey = "opensearch_custom_endpoint" PostgresFlexCustomEndpointKey = "postgresflex_custom_endpoint" + RabbitMQCustomEndpointKey = "rabbitmq_custom_endpoint" + RedisCustomEndpointKey = "redis_custom_endpoint" ResourceManagerEndpointKey = "resource_manager_custom_endpoint" ServiceAccountCustomEndpointKey = "service_account_custom_endpoint" SKECustomEndpointKey = "ske_custom_endpoint" @@ -49,6 +51,9 @@ var ConfigKeys = []string{ ResourceManagerEndpointKey, MembershipCustomEndpointKey, MongoDBFlexCustomEndpointKey, + RabbitMQCustomEndpointKey, + RedisCustomEndpointKey, + ResourceManagerEndpointKey, ServiceAccountCustomEndpointKey, SKECustomEndpointKey, } diff --git a/internal/pkg/services/opensearch/utils/utils.go b/internal/pkg/services/opensearch/utils/utils.go index 6d75de4e8..58219f52c 100644 --- a/internal/pkg/services/opensearch/utils/utils.go +++ b/internal/pkg/services/opensearch/utils/utils.go @@ -10,7 +10,11 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -func ValidatePlanId(service, planId string, offerings *opensearch.ListOfferingsResponse) error { +const ( + service = "opensearch" +) + +func ValidatePlanId(planId string, offerings *opensearch.ListOfferingsResponse) error { for _, offer := range *offerings.Offerings { for _, plan := range *offer.Plans { if plan.Id != nil && strings.EqualFold(*plan.Id, planId) { @@ -26,8 +30,6 @@ func ValidatePlanId(service, planId string, offerings *opensearch.ListOfferingsR } func LoadPlanId(planName, version string, offerings *opensearch.ListOfferingsResponse) (*string, error) { - service := "opensearch" - availableVersions := "" availablePlanNames := "" isValidVersion := false diff --git a/internal/pkg/services/rabbitmq/client/client.go b/internal/pkg/services/rabbitmq/client/client.go new file mode 100644 index 000000000..c31b00f1c --- /dev/null +++ b/internal/pkg/services/rabbitmq/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +func ConfigureClient(cmd *cobra.Command) (*rabbitmq.APIClient, error) { + var err error + var apiClient *rabbitmq.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) + + customEndpoint := viper.GetString(config.RabbitMQCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = rabbitmq.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/rabbitmq/utils/utils.go b/internal/pkg/services/rabbitmq/utils/utils.go new file mode 100644 index 000000000..6eced8759 --- /dev/null +++ b/internal/pkg/services/rabbitmq/utils/utils.go @@ -0,0 +1,87 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +const ( + service = "rabbitmq" +) + +func ValidatePlanId(planId string, offerings *rabbitmq.ListOfferingsResponse) error { + for _, offer := range *offerings.Offerings { + for _, plan := range *offer.Plans { + if plan.Id != nil && strings.EqualFold(*plan.Id, planId) { + return nil + } + } + } + + return &errors.DSAInvalidPlanError{ + Service: service, + Details: fmt.Sprintf("You provided plan ID %q, which is invalid.", planId), + } +} + +func LoadPlanId(planName, version string, offerings *rabbitmq.ListOfferingsResponse) (*string, error) { + availableVersions := "" + availablePlanNames := "" + isValidVersion := false + for _, offer := range *offerings.Offerings { + if !strings.EqualFold(*offer.Version, version) { + availableVersions = fmt.Sprintf("%s\n- %s", availableVersions, *offer.Version) + continue + } + isValidVersion = true + + for _, plan := range *offer.Plans { + if plan.Name == nil { + continue + } + if strings.EqualFold(*plan.Name, planName) && plan.Id != nil { + return plan.Id, nil + } + availablePlanNames = fmt.Sprintf("%s\n- %s", availablePlanNames, *plan.Name) + } + } + + if !isValidVersion { + details := fmt.Sprintf("You provided version %q, which is invalid. Available versions are: %s", version, availableVersions) + return nil, &errors.DSAInvalidPlanError{ + Service: service, + Details: details, + } + } + details := fmt.Sprintf("You provided plan_name %q for version %s, which is invalid. Available plan names for that version are: %s", planName, version, availablePlanNames) + return nil, &errors.DSAInvalidPlanError{ + Service: service, + Details: details, + } +} + +type RabbitMQClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*rabbitmq.Instance, error) + GetCredentialsExecute(ctx context.Context, projectId, instanceId, credentialsId string) (*rabbitmq.CredentialsResponse, error) +} + +func GetInstanceName(ctx context.Context, apiClient RabbitMQClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get RabbitMQ instance: %w", err) + } + return *resp.Name, nil +} + +func GetCredentialsUsername(ctx context.Context, apiClient RabbitMQClient, projectId, instanceId, credentialsId string) (string, error) { + resp, err := apiClient.GetCredentialsExecute(ctx, projectId, instanceId, credentialsId) + if err != nil { + return "", fmt.Errorf("get RabbitMQ credentials: %w", err) + } + return *resp.Raw.Credentials.Username, nil +} diff --git a/internal/pkg/services/rabbitmq/utils/utils_test.go b/internal/pkg/services/rabbitmq/utils/utils_test.go new file mode 100644 index 000000000..7c468c02c --- /dev/null +++ b/internal/pkg/services/rabbitmq/utils/utils_test.go @@ -0,0 +1,144 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testCredentialsId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testCredentialsUsername = "username" +) + +type rabbitMQClientMocked struct { + getInstanceFails bool + getInstanceResp *rabbitmq.Instance + getCredentialsFails bool + getCredentialsResp *rabbitmq.CredentialsResponse +} + +func (m *rabbitMQClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*rabbitmq.Instance, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} + +func (m *rabbitMQClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*rabbitmq.CredentialsResponse, error) { + if m.getCredentialsFails { + return nil, fmt.Errorf("could not get user") + } + return m.getCredentialsResp, nil +} + +func TestGetInstanceName(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *rabbitmq.Instance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &rabbitmq.Instance{ + Name: utils.Ptr(testInstanceName), + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &rabbitMQClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestGetCredentialsUsername(t *testing.T) { + tests := []struct { + description string + getCredentialsFails bool + getCredentialsResp *rabbitmq.CredentialsResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getCredentialsResp: &rabbitmq.CredentialsResponse{ + Raw: &rabbitmq.RawCredentials{ + Credentials: &rabbitmq.Credentials{ + Username: utils.Ptr(testCredentialsUsername), + }, + }, + }, + isValid: true, + expectedOutput: testCredentialsUsername, + }, + { + description: "get credentials fails", + getCredentialsFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &rabbitMQClientMocked{ + getCredentialsFails: tt.getCredentialsFails, + getCredentialsResp: tt.getCredentialsResp, + } + + output, err := GetCredentialsUsername(context.Background(), client, testProjectId, testInstanceId, testCredentialsId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} diff --git a/internal/pkg/services/redis/client/client.go b/internal/pkg/services/redis/client/client.go new file mode 100644 index 000000000..ace824e1d --- /dev/null +++ b/internal/pkg/services/redis/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +func ConfigureClient(cmd *cobra.Command) (*redis.APIClient, error) { + var err error + var apiClient *redis.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) + + customEndpoint := viper.GetString(config.RedisCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = redis.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/redis/utils/utils.go b/internal/pkg/services/redis/utils/utils.go new file mode 100644 index 000000000..c20444a6c --- /dev/null +++ b/internal/pkg/services/redis/utils/utils.go @@ -0,0 +1,87 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +const ( + service = "redis" +) + +func ValidatePlanId(planId string, offerings *redis.ListOfferingsResponse) error { + for _, offer := range *offerings.Offerings { + for _, plan := range *offer.Plans { + if plan.Id != nil && strings.EqualFold(*plan.Id, planId) { + return nil + } + } + } + + return &errors.DSAInvalidPlanError{ + Service: service, + Details: fmt.Sprintf("You provided plan ID %q, which is invalid.", planId), + } +} + +func LoadPlanId(planName, version string, offerings *redis.ListOfferingsResponse) (*string, error) { + availableVersions := "" + availablePlanNames := "" + isValidVersion := false + for _, offer := range *offerings.Offerings { + if !strings.EqualFold(*offer.Version, version) { + availableVersions = fmt.Sprintf("%s\n- %s", availableVersions, *offer.Version) + continue + } + isValidVersion = true + + for _, plan := range *offer.Plans { + if plan.Name == nil { + continue + } + if strings.EqualFold(*plan.Name, planName) && plan.Id != nil { + return plan.Id, nil + } + availablePlanNames = fmt.Sprintf("%s\n- %s", availablePlanNames, *plan.Name) + } + } + + if !isValidVersion { + details := fmt.Sprintf("You provided version %q, which is invalid. Available versions are: %s", version, availableVersions) + return nil, &errors.DSAInvalidPlanError{ + Service: service, + Details: details, + } + } + details := fmt.Sprintf("You provided plan_name %q for version %s, which is invalid. Available plan names for that version are: %s", planName, version, availablePlanNames) + return nil, &errors.DSAInvalidPlanError{ + Service: service, + Details: details, + } +} + +type RedisClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*redis.Instance, error) + GetCredentialsExecute(ctx context.Context, projectId, instanceId, credentialsId string) (*redis.CredentialsResponse, error) +} + +func GetInstanceName(ctx context.Context, apiClient RedisClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get Redis instance: %w", err) + } + return *resp.Name, nil +} + +func GetCredentialsUsername(ctx context.Context, apiClient RedisClient, projectId, instanceId, credentialsId string) (string, error) { + resp, err := apiClient.GetCredentialsExecute(ctx, projectId, instanceId, credentialsId) + if err != nil { + return "", fmt.Errorf("get Redis credentials: %w", err) + } + return *resp.Raw.Credentials.Username, nil +} diff --git a/internal/pkg/services/redis/utils/utils_test.go b/internal/pkg/services/redis/utils/utils_test.go new file mode 100644 index 000000000..83b00eb1a --- /dev/null +++ b/internal/pkg/services/redis/utils/utils_test.go @@ -0,0 +1,144 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testCredentialsId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testCredentialsUsername = "username" +) + +type redisClientMocked struct { + getInstanceFails bool + getInstanceResp *redis.Instance + getCredentialsFails bool + getCredentialsResp *redis.CredentialsResponse +} + +func (m *redisClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*redis.Instance, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} + +func (m *redisClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*redis.CredentialsResponse, error) { + if m.getCredentialsFails { + return nil, fmt.Errorf("could not get user") + } + return m.getCredentialsResp, nil +} + +func TestGetInstanceName(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *redis.Instance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &redis.Instance{ + Name: utils.Ptr(testInstanceName), + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &redisClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestGetCredentialsUsername(t *testing.T) { + tests := []struct { + description string + getCredentialsFails bool + getCredentialsResp *redis.CredentialsResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getCredentialsResp: &redis.CredentialsResponse{ + Raw: &redis.RawCredentials{ + Credentials: &redis.Credentials{ + Username: utils.Ptr(testCredentialsUsername), + }, + }, + }, + isValid: true, + expectedOutput: testCredentialsUsername, + }, + { + description: "get credentials fails", + getCredentialsFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &redisClientMocked{ + getCredentialsFails: tt.getCredentialsFails, + getCredentialsResp: tt.getCredentialsResp, + } + + output, err := GetCredentialsUsername(context.Background(), client, testProjectId, testInstanceId, testCredentialsId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +}