diff --git a/docs/stackit.md b/docs/stackit.md index dfbbadfa..ea7d6cc3 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -29,6 +29,8 @@ stackit [flags] * [stackit config](./stackit_config.md) - CLI configuration options * [stackit curl](./stackit_curl.md) - Executes an authenticated HTTP request to an endpoint * [stackit dns](./stackit_dns.md) - Provides functionality for DNS +* [stackit logme](./stackit_logme.md) - Provides functionality for LogMe +* [stackit mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB * [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex * [stackit opensearch](./stackit_opensearch.md) - Provides functionality for OpenSearch * [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index fc93b3c4..25ea63d7 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -31,6 +31,8 @@ stackit config set [flags] ``` --dns-custom-endpoint string DNS custom endpoint -h, --help Help for "stackit config set" + --logme-custom-endpoint string LogMe custom endpoint + --mariadb-custom-endpoint string MariaDB custom endpoint --membership-custom-endpoint string Membership custom endpoint --mongodbflex-custom-endpoint string MongoDB Flex custom endpoint --opensearch-custom-endpoint string OpenSearch custom endpoint diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 4895a8ef..9c4937be 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -29,6 +29,8 @@ stackit config unset [flags] --async Configuration option to run commands asynchronously --dns-custom-endpoint DNS custom endpoint -h, --help Help for "stackit config unset" + --logme-custom-endpoint LogMe custom endpoint + --mariadb-custom-endpoint MariaDB custom endpoint --membership-custom-endpoint Membership custom endpoint --mongodbflex-custom-endpoint MongoDB Flex custom endpoint --opensearch-custom-endpoint OpenSearch custom endpoint diff --git a/docs/stackit_logme.md b/docs/stackit_logme.md new file mode 100644 index 00000000..e8f60879 --- /dev/null +++ b/docs/stackit_logme.md @@ -0,0 +1,34 @@ +## stackit logme + +Provides functionality for LogMe + +### Synopsis + +Provides functionality for LogMe. + +``` +stackit logme [flags] +``` + +### Options + +``` + -h, --help Help for "stackit logme" +``` + +### 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 logme credentials](./stackit_logme_credentials.md) - Provides functionality for LogMe credentials +* [stackit logme instance](./stackit_logme_instance.md) - Provides functionality for LogMe instances +* [stackit logme plans](./stackit_logme_plans.md) - Lists all LogMe service plans + diff --git a/docs/stackit_logme_credentials.md b/docs/stackit_logme_credentials.md new file mode 100644 index 00000000..f36d81fa --- /dev/null +++ b/docs/stackit_logme_credentials.md @@ -0,0 +1,35 @@ +## stackit logme credentials + +Provides functionality for LogMe credentials + +### Synopsis + +Provides functionality for LogMe credentials. + +``` +stackit logme credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme](./stackit_logme.md) - Provides functionality for LogMe +* [stackit logme credentials create](./stackit_logme_credentials_create.md) - Creates credentials for a LogMe instance +* [stackit logme credentials delete](./stackit_logme_credentials_delete.md) - Deletes credentials of a LogMe instance +* [stackit logme credentials describe](./stackit_logme_credentials_describe.md) - Shows details of credentials of a LogMe instance +* [stackit logme credentials list](./stackit_logme_credentials_list.md) - Lists all credentials' IDs for a LogMe instance + diff --git a/docs/stackit_logme_credentials_create.md b/docs/stackit_logme_credentials_create.md new file mode 100644 index 00000000..8f16774a --- /dev/null +++ b/docs/stackit_logme_credentials_create.md @@ -0,0 +1,43 @@ +## stackit logme credentials create + +Creates credentials for a LogMe instance + +### Synopsis + +Creates credentials (username and password) for a LogMe instance. + +``` +stackit logme credentials create [flags] +``` + +### Examples + +``` + Create credentials for a LogMe instance + $ stackit logme credentials create --instance-id xxx + + Create credentials for a LogMe instance and hide the password in the output + $ stackit logme credentials create --instance-id xxx --hide-password +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme credentials](./stackit_logme_credentials.md) - Provides functionality for LogMe credentials + diff --git a/docs/stackit_logme_credentials_delete.md b/docs/stackit_logme_credentials_delete.md new file mode 100644 index 00000000..bf8f3c99 --- /dev/null +++ b/docs/stackit_logme_credentials_delete.md @@ -0,0 +1,39 @@ +## stackit logme credentials delete + +Deletes credentials of a LogMe instance + +### Synopsis + +Deletes credentials of a LogMe instance. + +``` +stackit logme credentials delete CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Delete credentials with ID "xxx" of LogMe instance with ID "yyy" + $ stackit logme credentials delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme credentials](./stackit_logme_credentials.md) - Provides functionality for LogMe credentials + diff --git a/docs/stackit_logme_credentials_describe.md b/docs/stackit_logme_credentials_describe.md new file mode 100644 index 00000000..f7edccd4 --- /dev/null +++ b/docs/stackit_logme_credentials_describe.md @@ -0,0 +1,42 @@ +## stackit logme credentials describe + +Shows details of credentials of a LogMe instance + +### Synopsis + +Shows details of credentials of a LogMe instance. The password will be shown in plain text in the output. + +``` +stackit logme credentials describe CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Get details of credentials of a LogMe instance with ID "xxx" from instance with ID "yyy" + $ stackit logme credentials describe xxx --instance-id yyy + + Get details of credentials of a LogMe instance with ID "xxx" from instance with ID "yyy" in a table format + $ stackit logme credentials describe xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme credentials](./stackit_logme_credentials.md) - Provides functionality for LogMe credentials + diff --git a/docs/stackit_logme_credentials_list.md b/docs/stackit_logme_credentials_list.md new file mode 100644 index 00000000..aa4738e1 --- /dev/null +++ b/docs/stackit_logme_credentials_list.md @@ -0,0 +1,46 @@ +## stackit logme credentials list + +Lists all credentials' IDs for a LogMe instance + +### Synopsis + +Lists all credentials' IDs for a LogMe instance. + +``` +stackit logme credentials list [flags] +``` + +### Examples + +``` + List all credentials' IDs for a LogMe instance + $ stackit logme credentials list --instance-id xxx + + List all credentials' IDs for a LogMe instance in JSON format + $ stackit logme credentials list --instance-id xxx --output-format json + + List up to 10 credentials' IDs for a LogMe instance + $ stackit logme credentials list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme credentials](./stackit_logme_credentials.md) - Provides functionality for LogMe credentials + diff --git a/docs/stackit_logme_instance.md b/docs/stackit_logme_instance.md new file mode 100644 index 00000000..23ddaad8 --- /dev/null +++ b/docs/stackit_logme_instance.md @@ -0,0 +1,36 @@ +## stackit logme instance + +Provides functionality for LogMe instances + +### Synopsis + +Provides functionality for LogMe instances. + +``` +stackit logme instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme](./stackit_logme.md) - Provides functionality for LogMe +* [stackit logme instance create](./stackit_logme_instance_create.md) - Creates a LogMe instance +* [stackit logme instance delete](./stackit_logme_instance_delete.md) - Deletes a LogMe instance +* [stackit logme instance describe](./stackit_logme_instance_describe.md) - Shows details of a LogMe instance +* [stackit logme instance list](./stackit_logme_instance_list.md) - Lists all LogMe instances +* [stackit logme instance update](./stackit_logme_instance_update.md) - Updates a LogMe instance + diff --git a/docs/stackit_logme_instance_create.md b/docs/stackit_logme_instance_create.md new file mode 100644 index 00000000..1fa58e59 --- /dev/null +++ b/docs/stackit_logme_instance_create.md @@ -0,0 +1,56 @@ +## stackit logme instance create + +Creates a LogMe instance + +### Synopsis + +Creates a LogMe instance. + +``` +stackit logme instance create [flags] +``` + +### Examples + +``` + Create a LogMe instance with name "my-instance" and specify plan by name and version + $ stackit logme instance create --name my-instance --plan-name stackit-logme2-1.2.50-replica --version 2 + + Create a LogMe instance with name "my-instance" and specify plan by ID + $ stackit logme instance create --name my-instance --plan-id xxx + + Create a LogMe instance with name "my-instance" and specify IP range which is allowed to access it + $ stackit logme 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 logme 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 LogMe 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 logme instance](./stackit_logme_instance.md) - Provides functionality for LogMe instances + diff --git a/docs/stackit_logme_instance_delete.md b/docs/stackit_logme_instance_delete.md new file mode 100644 index 00000000..2a9ac994 --- /dev/null +++ b/docs/stackit_logme_instance_delete.md @@ -0,0 +1,38 @@ +## stackit logme instance delete + +Deletes a LogMe instance + +### Synopsis + +Deletes a LogMe instance. + +``` +stackit logme instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a LogMe instance with ID "xxx" + $ stackit logme instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme instance](./stackit_logme_instance.md) - Provides functionality for LogMe instances + diff --git a/docs/stackit_logme_instance_describe.md b/docs/stackit_logme_instance_describe.md new file mode 100644 index 00000000..cad16a54 --- /dev/null +++ b/docs/stackit_logme_instance_describe.md @@ -0,0 +1,41 @@ +## stackit logme instance describe + +Shows details of a LogMe instance + +### Synopsis + +Shows details of a LogMe instance. + +``` +stackit logme instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a LogMe instance with ID "xxx" + $ stackit logme instance describe xxx + + Get details of a LogMe instance with ID "xxx" in a table format + $ stackit logme instance describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme instance](./stackit_logme_instance.md) - Provides functionality for LogMe instances + diff --git a/docs/stackit_logme_instance_list.md b/docs/stackit_logme_instance_list.md new file mode 100644 index 00000000..55697524 --- /dev/null +++ b/docs/stackit_logme_instance_list.md @@ -0,0 +1,45 @@ +## stackit logme instance list + +Lists all LogMe instances + +### Synopsis + +Lists all LogMe instances. + +``` +stackit logme instance list [flags] +``` + +### Examples + +``` + List all LogMe instances + $ stackit logme instance list + + List all LogMe instances in JSON format + $ stackit logme instance list --output-format json + + List up to 10 LogMe instances + $ stackit logme instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme instance](./stackit_logme_instance.md) - Provides functionality for LogMe instances + diff --git a/docs/stackit_logme_instance_update.md b/docs/stackit_logme_instance_update.md new file mode 100644 index 00000000..d2d89184 --- /dev/null +++ b/docs/stackit_logme_instance_update.md @@ -0,0 +1,52 @@ +## stackit logme instance update + +Updates a LogMe instance + +### Synopsis + +Updates a LogMe instance. + +``` +stackit logme instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the plan of a LogMe instance with ID "xxx" + $ stackit logme instance update xxx --plan-id yyy + + Update the range of IPs allowed to access a LogMe instance with ID "xxx" + $ stackit logme 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 logme 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 LogMe 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 logme instance](./stackit_logme_instance.md) - Provides functionality for LogMe instances + diff --git a/docs/stackit_logme_plans.md b/docs/stackit_logme_plans.md new file mode 100644 index 00000000..82a2290a --- /dev/null +++ b/docs/stackit_logme_plans.md @@ -0,0 +1,45 @@ +## stackit logme plans + +Lists all LogMe service plans + +### Synopsis + +Lists all LogMe service plans. + +``` +stackit logme plans [flags] +``` + +### Examples + +``` + List all LogMe service plans + $ stackit logme plans + + List all LogMe service plans in JSON format + $ stackit logme plans --output-format json + + List up to 10 LogMe service plans + $ stackit logme plans --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit logme 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 logme](./stackit_logme.md) - Provides functionality for LogMe + diff --git a/docs/stackit_mariadb.md b/docs/stackit_mariadb.md new file mode 100644 index 00000000..d7c2c7da --- /dev/null +++ b/docs/stackit_mariadb.md @@ -0,0 +1,34 @@ +## stackit mariadb + +Provides functionality for MariaDB + +### Synopsis + +Provides functionality for MariaDB. + +``` +stackit mariadb [flags] +``` + +### Options + +``` + -h, --help Help for "stackit mariadb" +``` + +### 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 mariadb credentials](./stackit_mariadb_credentials.md) - Provides functionality for MariaDB credentials +* [stackit mariadb instance](./stackit_mariadb_instance.md) - Provides functionality for MariaDB instances +* [stackit mariadb plans](./stackit_mariadb_plans.md) - Lists all MariaDB service plans + diff --git a/docs/stackit_mariadb_credentials.md b/docs/stackit_mariadb_credentials.md new file mode 100644 index 00000000..d32ee88a --- /dev/null +++ b/docs/stackit_mariadb_credentials.md @@ -0,0 +1,35 @@ +## stackit mariadb credentials + +Provides functionality for MariaDB credentials + +### Synopsis + +Provides functionality for MariaDB credentials. + +``` +stackit mariadb credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB +* [stackit mariadb credentials create](./stackit_mariadb_credentials_create.md) - Creates credentials for a MariaDB instance +* [stackit mariadb credentials delete](./stackit_mariadb_credentials_delete.md) - Deletes credentials of a MariaDB instance +* [stackit mariadb credentials describe](./stackit_mariadb_credentials_describe.md) - Shows details of credentials of a MariaDB instance +* [stackit mariadb credentials list](./stackit_mariadb_credentials_list.md) - Lists all credentials' IDs for a MariaDB instance + diff --git a/docs/stackit_mariadb_credentials_create.md b/docs/stackit_mariadb_credentials_create.md new file mode 100644 index 00000000..6d995122 --- /dev/null +++ b/docs/stackit_mariadb_credentials_create.md @@ -0,0 +1,43 @@ +## stackit mariadb credentials create + +Creates credentials for a MariaDB instance + +### Synopsis + +Creates credentials (username and password) for a MariaDB instance. + +``` +stackit mariadb credentials create [flags] +``` + +### Examples + +``` + Create credentials for a MariaDB instance + $ stackit mariadb credentials create --instance-id xxx + + Create credentials for a MariaDB instance and hide the password in the output + $ stackit mariadb credentials create --instance-id xxx --hide-password +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb credentials](./stackit_mariadb_credentials.md) - Provides functionality for MariaDB credentials + diff --git a/docs/stackit_mariadb_credentials_delete.md b/docs/stackit_mariadb_credentials_delete.md new file mode 100644 index 00000000..38c15d7c --- /dev/null +++ b/docs/stackit_mariadb_credentials_delete.md @@ -0,0 +1,39 @@ +## stackit mariadb credentials delete + +Deletes credentials of a MariaDB instance + +### Synopsis + +Deletes credentials of a MariaDB instance. + +``` +stackit mariadb credentials delete CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Delete credentials with ID "xxx" of MariaDB instance with ID "yyy" + $ stackit mariadb credentials delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb credentials](./stackit_mariadb_credentials.md) - Provides functionality for MariaDB credentials + diff --git a/docs/stackit_mariadb_credentials_describe.md b/docs/stackit_mariadb_credentials_describe.md new file mode 100644 index 00000000..800d2453 --- /dev/null +++ b/docs/stackit_mariadb_credentials_describe.md @@ -0,0 +1,42 @@ +## stackit mariadb credentials describe + +Shows details of credentials of a MariaDB instance + +### Synopsis + +Shows details of credentials of a MariaDB instance. The password will be shown in plain text in the output. + +``` +stackit mariadb credentials describe CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Get details of credentials of a MariaDB instance with ID "xxx" from instance with ID "yyy" + $ stackit mariadb credentials describe xxx --instance-id yyy + + Get details of credentials of a MariaDB instance with ID "xxx" from instance with ID "yyy" in a table format + $ stackit mariadb credentials describe xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb credentials](./stackit_mariadb_credentials.md) - Provides functionality for MariaDB credentials + diff --git a/docs/stackit_mariadb_credentials_list.md b/docs/stackit_mariadb_credentials_list.md new file mode 100644 index 00000000..b88ba008 --- /dev/null +++ b/docs/stackit_mariadb_credentials_list.md @@ -0,0 +1,46 @@ +## stackit mariadb credentials list + +Lists all credentials' IDs for a MariaDB instance + +### Synopsis + +Lists all credentials' IDs for a MariaDB instance. + +``` +stackit mariadb credentials list [flags] +``` + +### Examples + +``` + List all credentials' IDs for a MariaDB instance + $ stackit mariadb credentials list --instance-id xxx + + List all credentials' IDs for a MariaDB instance in JSON format + $ stackit mariadb credentials list --instance-id xxx --output-format json + + List up to 10 credentials' IDs for a MariaDB instance + $ stackit mariadb credentials list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb credentials](./stackit_mariadb_credentials.md) - Provides functionality for MariaDB credentials + diff --git a/docs/stackit_mariadb_instance.md b/docs/stackit_mariadb_instance.md new file mode 100644 index 00000000..877fca71 --- /dev/null +++ b/docs/stackit_mariadb_instance.md @@ -0,0 +1,36 @@ +## stackit mariadb instance + +Provides functionality for MariaDB instances + +### Synopsis + +Provides functionality for MariaDB instances. + +``` +stackit mariadb instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB +* [stackit mariadb instance create](./stackit_mariadb_instance_create.md) - Creates a MariaDB instance +* [stackit mariadb instance delete](./stackit_mariadb_instance_delete.md) - Deletes a MariaDB instance +* [stackit mariadb instance describe](./stackit_mariadb_instance_describe.md) - Shows details of a MariaDB instance +* [stackit mariadb instance list](./stackit_mariadb_instance_list.md) - Lists all MariaDB instances +* [stackit mariadb instance update](./stackit_mariadb_instance_update.md) - Updates a MariaDB instance + diff --git a/docs/stackit_mariadb_instance_create.md b/docs/stackit_mariadb_instance_create.md new file mode 100644 index 00000000..e0c2befe --- /dev/null +++ b/docs/stackit_mariadb_instance_create.md @@ -0,0 +1,56 @@ +## stackit mariadb instance create + +Creates a MariaDB instance + +### Synopsis + +Creates a MariaDB instance. + +``` +stackit mariadb instance create [flags] +``` + +### Examples + +``` + Create a MariaDB instance with name "my-instance" and specify plan by name and version + $ stackit mariadb instance create --name my-instance --plan-name stackit-mariadb-1.2.10-replica --version 10.6 + + Create a MariaDB instance with name "my-instance" and specify plan by ID + $ stackit mariadb instance create --name my-instance --plan-id xxx + + Create a MariaDB instance with name "my-instance" and specify IP range which is allowed to access it + $ stackit mariadb 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 mariadb 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 MariaDB 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 mariadb instance](./stackit_mariadb_instance.md) - Provides functionality for MariaDB instances + diff --git a/docs/stackit_mariadb_instance_delete.md b/docs/stackit_mariadb_instance_delete.md new file mode 100644 index 00000000..f5ee1cde --- /dev/null +++ b/docs/stackit_mariadb_instance_delete.md @@ -0,0 +1,38 @@ +## stackit mariadb instance delete + +Deletes a MariaDB instance + +### Synopsis + +Deletes a MariaDB instance. + +``` +stackit mariadb instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a MariaDB instance with ID "xxx" + $ stackit mariadb instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb instance](./stackit_mariadb_instance.md) - Provides functionality for MariaDB instances + diff --git a/docs/stackit_mariadb_instance_describe.md b/docs/stackit_mariadb_instance_describe.md new file mode 100644 index 00000000..53502faf --- /dev/null +++ b/docs/stackit_mariadb_instance_describe.md @@ -0,0 +1,41 @@ +## stackit mariadb instance describe + +Shows details of a MariaDB instance + +### Synopsis + +Shows details of a MariaDB instance. + +``` +stackit mariadb instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a MariaDB instance with ID "xxx" + $ stackit mariadb instance describe xxx + + Get details of a MariaDB instance with ID "xxx" in a table format + $ stackit mariadb instance describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb instance](./stackit_mariadb_instance.md) - Provides functionality for MariaDB instances + diff --git a/docs/stackit_mariadb_instance_list.md b/docs/stackit_mariadb_instance_list.md new file mode 100644 index 00000000..cf7e68bd --- /dev/null +++ b/docs/stackit_mariadb_instance_list.md @@ -0,0 +1,45 @@ +## stackit mariadb instance list + +Lists all MariaDB instances + +### Synopsis + +Lists all MariaDB instances. + +``` +stackit mariadb instance list [flags] +``` + +### Examples + +``` + List all MariaDB instances + $ stackit mariadb instance list + + List all MariaDB instances in JSON format + $ stackit mariadb instance list --output-format json + + List up to 10 MariaDB instances + $ stackit mariadb instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb instance](./stackit_mariadb_instance.md) - Provides functionality for MariaDB instances + diff --git a/docs/stackit_mariadb_instance_update.md b/docs/stackit_mariadb_instance_update.md new file mode 100644 index 00000000..f4d6655b --- /dev/null +++ b/docs/stackit_mariadb_instance_update.md @@ -0,0 +1,52 @@ +## stackit mariadb instance update + +Updates a MariaDB instance + +### Synopsis + +Updates a MariaDB instance. + +``` +stackit mariadb instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the plan of a MariaDB instance with ID "xxx" + $ stackit mariadb instance update xxx --plan-id yyy + + Update the range of IPs allowed to access a MariaDB instance with ID "xxx" + $ stackit mariadb 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 mariadb 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 MariaDB 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 mariadb instance](./stackit_mariadb_instance.md) - Provides functionality for MariaDB instances + diff --git a/docs/stackit_mariadb_plans.md b/docs/stackit_mariadb_plans.md new file mode 100644 index 00000000..461a3255 --- /dev/null +++ b/docs/stackit_mariadb_plans.md @@ -0,0 +1,45 @@ +## stackit mariadb plans + +Lists all MariaDB service plans + +### Synopsis + +Lists all MariaDB service plans. + +``` +stackit mariadb plans [flags] +``` + +### Examples + +``` + List all MariaDB service plans + $ stackit mariadb plans + + List all MariaDB service plans in JSON format + $ stackit mariadb plans --output-format json + + List up to 10 MariaDB service plans + $ stackit mariadb plans --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit mariadb 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 mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB + diff --git a/docs/stackit_opensearch_instance_update.md b/docs/stackit_opensearch_instance_update.md index 2e49c76f..2847cdf4 100644 --- a/docs/stackit_opensearch_instance_update.md +++ b/docs/stackit_opensearch_instance_update.md @@ -14,7 +14,7 @@ stackit opensearch instance update INSTANCE_ID [flags] ``` Update the plan of an OpenSearch instance with ID "xxx" - $ stackit opensearch instance update xxx --plan-id xxx + $ stackit opensearch instance update xxx --plan-id yyy Update the range of IPs allowed to access an OpenSearch instance with ID "xxx" $ stackit opensearch instance update xxx --acl 192.168.1.0/24 diff --git a/docs/stackit_rabbitmq_instance_update.md b/docs/stackit_rabbitmq_instance_update.md index f962420a..fe122827 100644 --- a/docs/stackit_rabbitmq_instance_update.md +++ b/docs/stackit_rabbitmq_instance_update.md @@ -14,7 +14,7 @@ stackit rabbitmq instance update INSTANCE_ID [flags] ``` Update the plan of an RabbitMQ instance with ID "xxx" - $ stackit rabbitmq instance update xxx --plan-id xxx + $ stackit rabbitmq instance update xxx --plan-id yyy 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 diff --git a/docs/stackit_redis_instance_update.md b/docs/stackit_redis_instance_update.md index aa2e3685..8af6b3fc 100644 --- a/docs/stackit_redis_instance_update.md +++ b/docs/stackit_redis_instance_update.md @@ -14,7 +14,7 @@ stackit redis instance update INSTANCE_ID [flags] ``` Update the plan of a Redis instance with ID "xxx" - $ stackit redis instance update xxx --plan-id xxx + $ stackit redis instance update xxx --plan-id yyy 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 diff --git a/go.mod b/go.mod index e60d0787..78e6516c 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/logme v0.10.0 + github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.10.0 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 diff --git a/go.sum b/go.sum index 5061f179..5b8162e5 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,10 @@ github.com/stackitcloud/stackit-sdk-go/core v0.7.7 h1:w1T8I2ERT5xPHXS+UV9maSseK8 github.com/stackitcloud/stackit-sdk-go/core v0.7.7/go.mod h1:nfC2Tbjd73Ivn8wEp3lvG/dmiXHgl6QX+HY2Ih3aGgw= github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.2 h1:Wj3A+BAitSK74dRMxEGoKU1itEZmjwrAECT/CgsEJOQ= github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.2/go.mod h1:RYRnST/3Kz5GmxMmFvsaYFblfZ/LMxw8r9DNfnRhX/4= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.10.0 h1:5+X0sKPdnLT/a1Jt73YphecQpPEOpd6TY6XdK0zGqP4= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.10.0/go.mod h1:e/I/8wJEqLdzdEgsGh7UbwbMMMNL5F1os25K+vQSzOc= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.10.0 h1:WxvySRZfkecOVjrpK3NiyqPL9YWKSpdBbIf55Y5HlNs= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.10.0/go.mod h1:GQ+UyuUOANO/kQOIhqLmMYRH34NbZShMrfmUY6hTSjo= github.com/stackitcloud/stackit-sdk-go/services/membership v0.3.4 h1:0OT/UBP55/GPMm9Tks9Uhb+PvP/2zI6ZUySfh7px+kY= github.com/stackitcloud/stackit-sdk-go/services/membership v0.3.4/go.mod h1:6ovfcQJ96ivkBpSI933lVl2a/SWprpVGoK6YNKycLps= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.10.3 h1:M7ALIg1tE8MFLLw9Um0iyvdBgIhl83tJ0sWRjP7YqMM= diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 90b72c8e..5089f453 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -19,6 +19,8 @@ const ( sessionTimeLimitFlag = "session-time-limit" dnsCustomEndpointFlag = "dns-custom-endpoint" + logMeCustomEndpointFlag = "logme-custom-endpoint" + mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" membershipCustomEndpointFlag = "membership-custom-endpoint" mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" openSearchCustomEndpointFlag = "opensearch-custom-endpoint" @@ -89,6 +91,8 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(sessionTimeLimitFlag, "", "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)") cmd.Flags().String(dnsCustomEndpointFlag, "", "DNS custom endpoint") + cmd.Flags().String(logMeCustomEndpointFlag, "", "LogMe custom endpoint") + cmd.Flags().String(mariaDBCustomEndpointFlag, "", "MariaDB custom endpoint") cmd.Flags().String(membershipCustomEndpointFlag, "", "Membership custom endpoint") cmd.Flags().String(mongoDBFlexCustomEndpointFlag, "", "MongoDB Flex custom endpoint") cmd.Flags().String(openSearchCustomEndpointFlag, "", "OpenSearch custom endpoint") @@ -101,6 +105,10 @@ func configureFlags(cmd *cobra.Command) { err := viper.BindPFlag(config.DNSCustomEndpointKey, cmd.Flags().Lookup(dnsCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.LogMeCustomEndpointKey, cmd.Flags().Lookup(logMeCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.MariaDBCustomEndpointKey, cmd.Flags().Lookup(mariaDBCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.MembershipCustomEndpointKey, cmd.Flags().Lookup(membershipCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.MongoDBFlexCustomEndpointKey, cmd.Flags().Lookup(mongoDBFlexCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 490def43..9db675ad 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -19,6 +19,8 @@ const ( projectIdFlag = globalflags.ProjectIdFlag dnsCustomEndpointFlag = "dns-custom-endpoint" + logMeCustomEndpointFlag = "logme-custom-endpoint" + mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" membershipCustomEndpointFlag = "membership-custom-endpoint" mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" openSearchCustomEndpointFlag = "opensearch-custom-endpoint" @@ -36,6 +38,8 @@ type inputModel struct { ProjectId bool DNSCustomEndpoint bool + LogMeCustomEndpoint bool + MariaDBCustomEndpoint bool MembershipCustomEndpoint bool MongoDBFlexCustomEndpoint bool OpenSearchCustomEndpoint bool @@ -80,6 +84,12 @@ func NewCmd() *cobra.Command { if model.DNSCustomEndpoint { viper.Set(config.DNSCustomEndpointKey, "") } + if model.LogMeCustomEndpoint { + viper.Set(config.LogMeCustomEndpointKey, "") + } + if model.MariaDBCustomEndpoint { + viper.Set(config.MariaDBCustomEndpointKey, "") + } if model.MembershipCustomEndpoint { viper.Set(config.MembershipCustomEndpointKey, "") } @@ -125,6 +135,8 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(outputFormatFlag, false, "Output format") cmd.Flags().Bool(dnsCustomEndpointFlag, false, "DNS custom endpoint") + cmd.Flags().Bool(logMeCustomEndpointFlag, false, "LogMe custom endpoint") + cmd.Flags().Bool(mariaDBCustomEndpointFlag, false, "MariaDB custom endpoint") cmd.Flags().Bool(membershipCustomEndpointFlag, false, "Membership custom endpoint") cmd.Flags().Bool(mongoDBFlexCustomEndpointFlag, false, "MongoDB Flex custom endpoint") cmd.Flags().Bool(openSearchCustomEndpointFlag, false, "OpenSearch custom endpoint") @@ -143,6 +155,8 @@ func parseInput(cmd *cobra.Command) *inputModel { ProjectId: flags.FlagToBoolValue(cmd, projectIdFlag), DNSCustomEndpoint: flags.FlagToBoolValue(cmd, dnsCustomEndpointFlag), + LogMeCustomEndpoint: flags.FlagToBoolValue(cmd, logMeCustomEndpointFlag), + MariaDBCustomEndpoint: flags.FlagToBoolValue(cmd, mariaDBCustomEndpointFlag), MembershipCustomEndpoint: flags.FlagToBoolValue(cmd, membershipCustomEndpointFlag), MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(cmd, mongoDBFlexCustomEndpointFlag), OpenSearchCustomEndpoint: flags.FlagToBoolValue(cmd, openSearchCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index d7fdc7ee..4c3d389d 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -13,6 +13,8 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool outputFormatFlag: true, dnsCustomEndpointFlag: true, + logMeCustomEndpointFlag: true, + mariaDBCustomEndpointFlag: true, openSearchCustomEndpointFlag: true, rabbitMQCustomEndpointFlag: true, redisCustomEndpointFlag: true, @@ -31,6 +33,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { ProjectId: true, OutputFormat: true, DNSCustomEndpoint: true, + LogMeCustomEndpoint: true, + MariaDBCustomEndpoint: true, ServiceAccountCustomEndpoint: true, SKECustomEndpoint: true, ResourceManagerCustomEndpoint: true, @@ -65,12 +69,14 @@ func TestParseInput(t *testing.T) { model.ProjectId = false model.OutputFormat = false model.DNSCustomEndpoint = false - model.ServiceAccountCustomEndpoint = false - model.SKECustomEndpoint = false - model.ResourceManagerCustomEndpoint = false + model.LogMeCustomEndpoint = false + model.MariaDBCustomEndpoint = false model.OpenSearchCustomEndpoint = false - model.RedisCustomEndpoint = false model.RabbitMQCustomEndpoint = false + model.RedisCustomEndpoint = false + model.ResourceManagerCustomEndpoint = false + model.ServiceAccountCustomEndpoint = false + model.SKECustomEndpoint = false }), }, { diff --git a/internal/cmd/logme/credentials/create/create.go b/internal/cmd/logme/credentials/create/create.go new file mode 100644 index 00000000..8feb5d91 --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" +) + +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 LogMe instance", + Long: "Creates credentials (username and password) for a LogMe instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create credentials for a LogMe instance`, + "$ stackit logme credentials create --instance-id xxx"), + examples.NewExample( + `Create credentials for a LogMe instance and hide the password in the output`, + "$ stackit logme 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 := logmeUtils.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 LogMe 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 *logme.APIClient) logme.ApiCreateCredentialsRequest { + req := apiClient.CreateCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/logme/credentials/create/create_test.go b/internal/cmd/logme/credentials/create/create_test.go new file mode 100644 index 00000000..6cf81ee9 --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiCreateCredentialsRequest)) logme.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 logme.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/logme/credentials/credentials.go b/internal/cmd/logme/credentials/credentials.go new file mode 100644 index 00000000..64195699 --- /dev/null +++ b/internal/cmd/logme/credentials/credentials.go @@ -0,0 +1,31 @@ +package credentials + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/credentials/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/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 LogMe credentials", + Long: "Provides functionality for LogMe 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/logme/credentials/delete/delete.go b/internal/cmd/logme/credentials/delete/delete.go new file mode 100644 index 00000000..3b3ced0e --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" +) + +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 LogMe instance", + Long: "Deletes credentials of a LogMe instance.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete credentials with ID "xxx" of LogMe instance with ID "yyy"`, + "$ stackit logme 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 := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + credentialsLabel, err := logmeUtils.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 LogMe 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 *logme.APIClient) logme.ApiDeleteCredentialsRequest { + req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} diff --git a/internal/cmd/logme/credentials/delete/delete_test.go b/internal/cmd/logme/credentials/delete/delete_test.go new file mode 100644 index 00000000..c7ea5a33 --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiDeleteCredentialsRequest)) logme.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 logme.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/logme/credentials/describe/describe.go b/internal/cmd/logme/credentials/describe/describe.go new file mode 100644 index 00000000..2f4c6d9a --- /dev/null +++ b/internal/cmd/logme/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/logme/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/logme" +) + +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 LogMe instance", + Long: "Shows details of credentials of a LogMe 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 LogMe instance with ID "xxx" from instance with ID "yyy"`, + "$ stackit logme credentials describe xxx --instance-id yyy"), + examples.NewExample( + `Get details of credentials of a LogMe instance with ID "xxx" from instance with ID "yyy" in a table format`, + "$ stackit logme 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 LogMe 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 *logme.APIClient) logme.ApiGetCredentialsRequest { + req := apiClient.GetCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials *logme.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 LogMe credentials: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/logme/credentials/describe/describe_test.go b/internal/cmd/logme/credentials/describe/describe_test.go new file mode 100644 index 00000000..b157ff65 --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiGetCredentialsRequest)) logme.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 logme.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/logme/credentials/list/list.go b/internal/cmd/logme/credentials/list/list.go new file mode 100644 index 00000000..64274274 --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" +) + +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 LogMe instance", + Long: "Lists all credentials' IDs for a LogMe instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all credentials' IDs for a LogMe instance`, + "$ stackit logme credentials list --instance-id xxx"), + examples.NewExample( + `List all credentials' IDs for a LogMe instance in JSON format`, + "$ stackit logme credentials list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 credentials' IDs for a LogMe instance`, + "$ stackit logme 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 LogMe credentialss: %w", err) + } + credentials := *resp.CredentialsList + if len(credentials) == 0 { + instanceLabel, err := logmeUtils.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 *logme.APIClient) logme.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials []logme.CredentialsListItem) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal LogMe 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/logme/credentials/list/list_test.go b/internal/cmd/logme/credentials/list/list_test.go new file mode 100644 index 00000000..7f3cb8fa --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiListCredentialsRequest)) logme.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 logme.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/logme/instance/create/create.go b/internal/cmd/logme/instance/create/create.go new file mode 100644 index 00000000..a3fb1efc --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/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/logme" + "github.com/stackitcloud/stackit-sdk-go/services/logme/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 LogMe instance", + Long: "Creates a LogMe instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a LogMe instance with name "my-instance" and specify plan by name and version`, + "$ stackit logme instance create --name my-instance --plan-name stackit-logme2-1.2.50-replica --version 2"), + examples.NewExample( + `Create a LogMe instance with name "my-instance" and specify plan by ID`, + "$ stackit logme instance create --name my-instance --plan-id xxx"), + examples.NewExample( + `Create a LogMe instance with name "my-instance" and specify IP range which is allowed to access it`, + "$ stackit logme 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 LogMe 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 LogMe instance creation request: %w", err) + } + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create LogMe 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 LogMe 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 LogMe 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 logMeClient interface { + CreateInstance(ctx context.Context, projectId string) logme.ApiCreateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*logme.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient logMeClient) (logme.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 LogMe offerings: %w", err) + } + + if model.PlanId == nil { + planId, err = logmeUtils.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 := logmeUtils.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(logme.CreateInstancePayload{ + InstanceName: model.InstanceName, + Parameters: &logme.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/logme/instance/create/create_test.go b/internal/cmd/logme/instance/create/create_test.go new file mode 100644 index 00000000..cac6708d --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.APIClient{} + +type logMeClientMocked struct { + returnError bool + listOfferingsResp *logme.ListOfferingsResponse +} + +func (c *logMeClientMocked) CreateInstance(ctx context.Context, projectId string) logme.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId) +} + +func (c *logMeClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*logme.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 *logme.ApiCreateInstanceRequest)) logme.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + request = request.CreateInstancePayload(logme.CreateInstancePayload{ + InstanceName: utils.Ptr("example-name"), + Parameters: &logme.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 logme.ApiCreateInstanceRequest + getOfferingsFails bool + getOfferingsResp *logme.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getOfferingsResp: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.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: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.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: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.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: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + expectedRequest: testClient.CreateInstance(testCtx, testProjectId). + CreateInstancePayload(logme.CreateInstancePayload{PlanId: utils.Ptr(testPlanId), Parameters: &logme.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &logMeClientMocked{ + 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/logme/instance/delete/delete.go b/internal/cmd/logme/instance/delete/delete.go new file mode 100644 index 00000000..62273744 --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/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/logme" + "github.com/stackitcloud/stackit-sdk-go/services/logme/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 LogMe instance", + Long: "Deletes a LogMe instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a LogMe instance with ID "xxx"`, + "$ stackit logme 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 := logmeUtils.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 LogMe 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 LogMe 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 *logme.APIClient) logme.ApiDeleteInstanceRequest { + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/logme/instance/delete/delete_test.go b/internal/cmd/logme/instance/delete/delete_test.go new file mode 100644 index 00000000..41863912 --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiDeleteInstanceRequest)) logme.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 logme.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/logme/instance/describe/describe.go b/internal/cmd/logme/instance/describe/describe.go new file mode 100644 index 00000000..0b77041a --- /dev/null +++ b/internal/cmd/logme/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/logme/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/logme" +) + +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 LogMe instance", + Long: "Shows details of a LogMe instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a LogMe instance with ID "xxx"`, + "$ stackit logme instance describe xxx"), + examples.NewExample( + `Get details of a LogMe instance with ID "xxx" in a table format`, + "$ stackit logme 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 LogMe 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 *logme.APIClient) logme.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instance *logme.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 LogMe instance: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/logme/instance/describe/describe_test.go b/internal/cmd/logme/instance/describe/describe_test.go new file mode 100644 index 00000000..cbfa438c --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiGetInstanceRequest)) logme.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 logme.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/logme/instance/instance.go b/internal/cmd/logme/instance/instance.go new file mode 100644 index 00000000..69877a40 --- /dev/null +++ b/internal/cmd/logme/instance/instance.go @@ -0,0 +1,33 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/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 LogMe instances", + Long: "Provides functionality for LogMe 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/logme/instance/list/list.go b/internal/cmd/logme/instance/list/list.go new file mode 100644 index 00000000..68844d06 --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all LogMe instances", + Long: "Lists all LogMe instances.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all LogMe instances`, + "$ stackit logme instance list"), + examples.NewExample( + `List all LogMe instances in JSON format`, + "$ stackit logme instance list --output-format json"), + examples.NewExample( + `List up to 10 LogMe instances`, + "$ stackit logme 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 LogMe 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 *logme.APIClient) logme.ApiListInstancesRequest { + req := apiClient.ListInstances(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instances []logme.Instance) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal LogMe 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/logme/instance/list/list_test.go b/internal/cmd/logme/instance/list/list_test.go new file mode 100644 index 00000000..eb6f9faa --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiListInstancesRequest)) logme.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 logme.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/logme/instance/update/update.go b/internal/cmd/logme/instance/update/update.go new file mode 100644 index 00000000..a8186c71 --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/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/logme" + "github.com/stackitcloud/stackit-sdk-go/services/logme/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 LogMe instance", + Long: "Updates a LogMe instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the plan of a LogMe instance with ID "xxx"`, + "$ stackit logme instance update xxx --plan-id yyy"), + examples.NewExample( + `Update the range of IPs allowed to access a LogMe instance with ID "xxx"`, + "$ stackit logme 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 := logmeUtils.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 LogMe instance update request: %w", err) + } + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("update LogMe 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 LogMe 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 LogMe 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 logMeClient interface { + PartialUpdateInstance(ctx context.Context, projectId, instanceId string) logme.ApiPartialUpdateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*logme.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient logMeClient) (logme.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 LogMe offerings: %w", err) + } + + if model.PlanId == nil && model.PlanName != "" && model.Version != "" { + planId, err = logmeUtils.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 := logmeUtils.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(logme.PartialUpdateInstancePayload{ + Parameters: &logme.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/logme/instance/update/update_test.go b/internal/cmd/logme/instance/update/update_test.go new file mode 100644 index 00000000..e2da035a --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.APIClient{} + +type logMeClientMocked struct { + returnError bool + listOfferingsResp *logme.ListOfferingsResponse +} + +func (c *logMeClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) logme.ApiPartialUpdateInstanceRequest { + return testClient.PartialUpdateInstance(ctx, projectId, instanceId) +} + +func (c *logMeClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*logme.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 *logme.ApiPartialUpdateInstanceRequest)) logme.ApiPartialUpdateInstanceRequest { + request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId) + request = request.PartialUpdateInstancePayload(logme.PartialUpdateInstancePayload{ + Parameters: &logme.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 logme.ApiPartialUpdateInstanceRequest + getOfferingsFails bool + listOfferingsResp *logme.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + listOfferingsResp: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.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: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.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: &logme.ListOfferingsResponse{ + Offerings: &[]logme.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]logme.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(logme.PartialUpdateInstancePayload{Parameters: &logme.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &logMeClientMocked{ + 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/logme/logme.go b/internal/cmd/logme/logme.go new file mode 100644 index 00000000..8880bfff --- /dev/null +++ b/internal/cmd/logme/logme.go @@ -0,0 +1,29 @@ +package logme + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme/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: "logme", + Short: "Provides functionality for LogMe", + Long: "Provides functionality for LogMe.", + 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/logme/plans/plans.go b/internal/cmd/logme/plans/plans.go new file mode 100644 index 00000000..d5a84d47 --- /dev/null +++ b/internal/cmd/logme/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/logme/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all LogMe service plans", + Long: "Lists all LogMe service plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all LogMe service plans`, + "$ stackit logme plans"), + examples.NewExample( + `List all LogMe service plans in JSON format`, + "$ stackit logme plans --output-format json"), + examples.NewExample( + `List up to 10 LogMe service plans`, + "$ stackit logme 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 LogMe 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 *logme.APIClient) logme.ApiListOfferingsRequest { + req := apiClient.ListOfferings(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, plans []logme.Offering) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(plans, "", " ") + if err != nil { + return fmt.Errorf("marshal LogMe 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/logme/plans/plans_test.go b/internal/cmd/logme/plans/plans_test.go new file mode 100644 index 00000000..49045f70 --- /dev/null +++ b/internal/cmd/logme/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/logme" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logme.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 *logme.ApiListOfferingsRequest)) logme.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 logme.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/mariadb/credentials/create/create.go b/internal/cmd/mariadb/credentials/create/create.go new file mode 100644 index 00000000..a9b1467f --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" +) + +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 MariaDB instance", + Long: "Creates credentials (username and password) for a MariaDB instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create credentials for a MariaDB instance`, + "$ stackit mariadb credentials create --instance-id xxx"), + examples.NewExample( + `Create credentials for a MariaDB instance and hide the password in the output`, + "$ stackit mariadb 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 := mariadbUtils.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 MariaDB 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 *mariadb.APIClient) mariadb.ApiCreateCredentialsRequest { + req := apiClient.CreateCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/mariadb/credentials/create/create_test.go b/internal/cmd/mariadb/credentials/create/create_test.go new file mode 100644 index 00000000..a47d55d5 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiCreateCredentialsRequest)) mariadb.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 mariadb.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/mariadb/credentials/credentials.go b/internal/cmd/mariadb/credentials/credentials.go new file mode 100644 index 00000000..a5bbfd8c --- /dev/null +++ b/internal/cmd/mariadb/credentials/credentials.go @@ -0,0 +1,31 @@ +package credentials + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/credentials/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/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 MariaDB credentials", + Long: "Provides functionality for MariaDB 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/mariadb/credentials/delete/delete.go b/internal/cmd/mariadb/credentials/delete/delete.go new file mode 100644 index 00000000..3bc724dc --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" +) + +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 MariaDB instance", + Long: "Deletes credentials of a MariaDB instance.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete credentials with ID "xxx" of MariaDB instance with ID "yyy"`, + "$ stackit mariadb 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 := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + credentialsLabel, err := mariadbUtils.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 MariaDB 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 *mariadb.APIClient) mariadb.ApiDeleteCredentialsRequest { + req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} diff --git a/internal/cmd/mariadb/credentials/delete/delete_test.go b/internal/cmd/mariadb/credentials/delete/delete_test.go new file mode 100644 index 00000000..ed6f81c9 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiDeleteCredentialsRequest)) mariadb.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 mariadb.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/mariadb/credentials/describe/describe.go b/internal/cmd/mariadb/credentials/describe/describe.go new file mode 100644 index 00000000..87180f8b --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/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/mariadb" +) + +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 MariaDB instance", + Long: "Shows details of credentials of a MariaDB 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 MariaDB instance with ID "xxx" from instance with ID "yyy"`, + "$ stackit mariadb credentials describe xxx --instance-id yyy"), + examples.NewExample( + `Get details of credentials of a MariaDB instance with ID "xxx" from instance with ID "yyy" in a table format`, + "$ stackit mariadb 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 MariaDB 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 *mariadb.APIClient) mariadb.ApiGetCredentialsRequest { + req := apiClient.GetCredentials(ctx, model.ProjectId, model.InstanceId, model.CredentialsId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials *mariadb.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 MariaDB credentials: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/mariadb/credentials/describe/describe_test.go b/internal/cmd/mariadb/credentials/describe/describe_test.go new file mode 100644 index 00000000..15491380 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiGetCredentialsRequest)) mariadb.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 mariadb.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/mariadb/credentials/list/list.go b/internal/cmd/mariadb/credentials/list/list.go new file mode 100644 index 00000000..502d84c4 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" +) + +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 MariaDB instance", + Long: "Lists all credentials' IDs for a MariaDB instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all credentials' IDs for a MariaDB instance`, + "$ stackit mariadb credentials list --instance-id xxx"), + examples.NewExample( + `List all credentials' IDs for a MariaDB instance in JSON format`, + "$ stackit mariadb credentials list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 credentials' IDs for a MariaDB instance`, + "$ stackit mariadb 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 MariaDB credentialss: %w", err) + } + credentials := *resp.CredentialsList + if len(credentials) == 0 { + instanceLabel, err := mariadbUtils.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 *mariadb.APIClient) mariadb.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credentials []mariadb.CredentialsListItem) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal MariaDB 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/mariadb/credentials/list/list_test.go b/internal/cmd/mariadb/credentials/list/list_test.go new file mode 100644 index 00000000..56b2c255 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiListCredentialsRequest)) mariadb.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 mariadb.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/mariadb/instance/create/create.go b/internal/cmd/mariadb/instance/create/create.go new file mode 100644 index 00000000..cc1b865e --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/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/mariadb" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb/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 MariaDB instance", + Long: "Creates a MariaDB instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a MariaDB instance with name "my-instance" and specify plan by name and version`, + "$ stackit mariadb instance create --name my-instance --plan-name stackit-mariadb-1.2.10-replica --version 10.6"), + examples.NewExample( + `Create a MariaDB instance with name "my-instance" and specify plan by ID`, + "$ stackit mariadb instance create --name my-instance --plan-id xxx"), + examples.NewExample( + `Create a MariaDB instance with name "my-instance" and specify IP range which is allowed to access it`, + "$ stackit mariadb 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 MariaDB 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 MariaDB instance creation request: %w", err) + } + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create MariaDB 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 MariaDB 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 MariaDB 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 mariaDBClient interface { + CreateInstance(ctx context.Context, projectId string) mariadb.ApiCreateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*mariadb.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient mariaDBClient) (mariadb.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 MariaDB offerings: %w", err) + } + + if model.PlanId == nil { + planId, err = mariadbUtils.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 := mariadbUtils.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(mariadb.CreateInstancePayload{ + InstanceName: model.InstanceName, + Parameters: &mariadb.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/mariadb/instance/create/create_test.go b/internal/cmd/mariadb/instance/create/create_test.go new file mode 100644 index 00000000..db3eb811 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.APIClient{} + +type mariaDBClientMocked struct { + returnError bool + listOfferingsResp *mariadb.ListOfferingsResponse +} + +func (c *mariaDBClientMocked) CreateInstance(ctx context.Context, projectId string) mariadb.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId) +} + +func (c *mariaDBClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*mariadb.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 *mariadb.ApiCreateInstanceRequest)) mariadb.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + request = request.CreateInstancePayload(mariadb.CreateInstancePayload{ + InstanceName: utils.Ptr("example-name"), + Parameters: &mariadb.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 mariadb.ApiCreateInstanceRequest + getOfferingsFails bool + getOfferingsResp *mariadb.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getOfferingsResp: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.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: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.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: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.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: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + }, + }, + }, + expectedRequest: testClient.CreateInstance(testCtx, testProjectId). + CreateInstancePayload(mariadb.CreateInstancePayload{PlanId: utils.Ptr(testPlanId), Parameters: &mariadb.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &mariaDBClientMocked{ + 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/mariadb/instance/delete/delete.go b/internal/cmd/mariadb/instance/delete/delete.go new file mode 100644 index 00000000..bbb2031b --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/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/mariadb" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb/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 MariaDB instance", + Long: "Deletes a MariaDB instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a MariaDB instance with ID "xxx"`, + "$ stackit mariadb 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 := mariadbUtils.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 MariaDB 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 MariaDB 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 *mariadb.APIClient) mariadb.ApiDeleteInstanceRequest { + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/mariadb/instance/delete/delete_test.go b/internal/cmd/mariadb/instance/delete/delete_test.go new file mode 100644 index 00000000..554580b6 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiDeleteInstanceRequest)) mariadb.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 mariadb.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/mariadb/instance/describe/describe.go b/internal/cmd/mariadb/instance/describe/describe.go new file mode 100644 index 00000000..c9e0bf7a --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/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/mariadb" +) + +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 MariaDB instance", + Long: "Shows details of a MariaDB instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a MariaDB instance with ID "xxx"`, + "$ stackit mariadb instance describe xxx"), + examples.NewExample( + `Get details of a MariaDB instance with ID "xxx" in a table format`, + "$ stackit mariadb 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 MariaDB 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 *mariadb.APIClient) mariadb.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instance *mariadb.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 MariaDB instance: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/mariadb/instance/describe/describe_test.go b/internal/cmd/mariadb/instance/describe/describe_test.go new file mode 100644 index 00000000..aaeb3d5f --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiGetInstanceRequest)) mariadb.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 mariadb.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/mariadb/instance/instance.go b/internal/cmd/mariadb/instance/instance.go new file mode 100644 index 00000000..dc68e87e --- /dev/null +++ b/internal/cmd/mariadb/instance/instance.go @@ -0,0 +1,33 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/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 MariaDB instances", + Long: "Provides functionality for MariaDB 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/mariadb/instance/list/list.go b/internal/cmd/mariadb/instance/list/list.go new file mode 100644 index 00000000..4f9425d1 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all MariaDB instances", + Long: "Lists all MariaDB instances.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all MariaDB instances`, + "$ stackit mariadb instance list"), + examples.NewExample( + `List all MariaDB instances in JSON format`, + "$ stackit mariadb instance list --output-format json"), + examples.NewExample( + `List up to 10 MariaDB instances`, + "$ stackit mariadb 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 MariaDB 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 *mariadb.APIClient) mariadb.ApiListInstancesRequest { + req := apiClient.ListInstances(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instances []mariadb.Instance) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal MariaDB 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/mariadb/instance/list/list_test.go b/internal/cmd/mariadb/instance/list/list_test.go new file mode 100644 index 00000000..a730195b --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiListInstancesRequest)) mariadb.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 mariadb.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/mariadb/instance/update/update.go b/internal/cmd/mariadb/instance/update/update.go new file mode 100644 index 00000000..52b2dc7f --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/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/mariadb" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb/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 MariaDB instance", + Long: "Updates a MariaDB instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the plan of a MariaDB instance with ID "xxx"`, + "$ stackit mariadb instance update xxx --plan-id yyy"), + examples.NewExample( + `Update the range of IPs allowed to access a MariaDB instance with ID "xxx"`, + "$ stackit mariadb 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 := mariadbUtils.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 MariaDB instance update request: %w", err) + } + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("update MariaDB 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 MariaDB 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 MariaDB 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 mariaDBClient interface { + PartialUpdateInstance(ctx context.Context, projectId, instanceId string) mariadb.ApiPartialUpdateInstanceRequest + ListOfferingsExecute(ctx context.Context, projectId string) (*mariadb.ListOfferingsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient mariaDBClient) (mariadb.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 MariaDB offerings: %w", err) + } + + if model.PlanId == nil && model.PlanName != "" && model.Version != "" { + planId, err = mariadbUtils.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 := mariadbUtils.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(mariadb.PartialUpdateInstancePayload{ + Parameters: &mariadb.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/mariadb/instance/update/update_test.go b/internal/cmd/mariadb/instance/update/update_test.go new file mode 100644 index 00000000..19a1b0ea --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.APIClient{} + +type mariaDBClientMocked struct { + returnError bool + listOfferingsResp *mariadb.ListOfferingsResponse +} + +func (c *mariaDBClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) mariadb.ApiPartialUpdateInstanceRequest { + return testClient.PartialUpdateInstance(ctx, projectId, instanceId) +} + +func (c *mariaDBClientMocked) ListOfferingsExecute(_ context.Context, _ string) (*mariadb.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 *mariadb.ApiPartialUpdateInstanceRequest)) mariadb.ApiPartialUpdateInstanceRequest { + request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId) + request = request.PartialUpdateInstancePayload(mariadb.PartialUpdateInstancePayload{ + Parameters: &mariadb.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 mariadb.ApiPartialUpdateInstanceRequest + getOfferingsFails bool + listOfferingsResp *mariadb.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + listOfferingsResp: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.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: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.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: &mariadb.ListOfferingsResponse{ + Offerings: &[]mariadb.Offering{ + { + Version: utils.Ptr("example-version"), + Plans: &[]mariadb.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(mariadb.PartialUpdateInstancePayload{Parameters: &mariadb.InstanceParameters{}}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &mariaDBClientMocked{ + 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/mariadb/mariadb.go b/internal/cmd/mariadb/mariadb.go new file mode 100644 index 00000000..e8f9f647 --- /dev/null +++ b/internal/cmd/mariadb/mariadb.go @@ -0,0 +1,29 @@ +package mariadb + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/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: "mariadb", + Short: "Provides functionality for MariaDB", + Long: "Provides functionality for MariaDB.", + 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/mariadb/plans/plans.go b/internal/cmd/mariadb/plans/plans.go new file mode 100644 index 00000000..eb99a670 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all MariaDB service plans", + Long: "Lists all MariaDB service plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all MariaDB service plans`, + "$ stackit mariadb plans"), + examples.NewExample( + `List all MariaDB service plans in JSON format`, + "$ stackit mariadb plans --output-format json"), + examples.NewExample( + `List up to 10 MariaDB service plans`, + "$ stackit mariadb 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 MariaDB 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 *mariadb.APIClient) mariadb.ApiListOfferingsRequest { + req := apiClient.ListOfferings(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, plans []mariadb.Offering) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(plans, "", " ") + if err != nil { + return fmt.Errorf("marshal MariaDB 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/mariadb/plans/plans_test.go b/internal/cmd/mariadb/plans/plans_test.go new file mode 100644 index 00000000..fe2d9644 --- /dev/null +++ b/internal/cmd/mariadb/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/mariadb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mariadb.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 *mariadb.ApiListOfferingsRequest)) mariadb.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 mariadb.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/opensearch/instance/update/update.go b/internal/cmd/opensearch/instance/update/update.go index c432147a..9271f911 100644 --- a/internal/cmd/opensearch/instance/update/update.go +++ b/internal/cmd/opensearch/instance/update/update.go @@ -65,7 +65,7 @@ func NewCmd() *cobra.Command { Example: examples.Build( examples.NewExample( `Update the plan of an OpenSearch instance with ID "xxx"`, - "$ stackit opensearch instance update xxx --plan-id xxx"), + "$ stackit opensearch instance update xxx --plan-id yyy"), examples.NewExample( `Update the range of IPs allowed to access an OpenSearch instance with ID "xxx"`, "$ stackit opensearch instance update xxx --acl 192.168.1.0/24"), diff --git a/internal/cmd/rabbitmq/instance/update/update.go b/internal/cmd/rabbitmq/instance/update/update.go index c8bcd408..3ccd3138 100644 --- a/internal/cmd/rabbitmq/instance/update/update.go +++ b/internal/cmd/rabbitmq/instance/update/update.go @@ -65,7 +65,7 @@ func NewCmd() *cobra.Command { Example: examples.Build( examples.NewExample( `Update the plan of an RabbitMQ instance with ID "xxx"`, - "$ stackit rabbitmq instance update xxx --plan-id xxx"), + "$ stackit rabbitmq instance update xxx --plan-id yyy"), 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"), diff --git a/internal/cmd/redis/instance/update/update.go b/internal/cmd/redis/instance/update/update.go index a499e1a3..797bb018 100644 --- a/internal/cmd/redis/instance/update/update.go +++ b/internal/cmd/redis/instance/update/update.go @@ -65,7 +65,7 @@ func NewCmd() *cobra.Command { Example: examples.Build( examples.NewExample( `Update the plan of a Redis instance with ID "xxx"`, - "$ stackit redis instance update xxx --plan-id xxx"), + "$ stackit redis instance update xxx --plan-id yyy"), 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"), diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 468eaf65..d8fafa45 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -10,6 +10,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/config" "github.com/stackitcloud/stackit-cli/internal/cmd/curl" "github.com/stackitcloud/stackit-cli/internal/cmd/dns" + "github.com/stackitcloud/stackit-cli/internal/cmd/logme" + "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex" "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch" "github.com/stackitcloud/stackit-cli/internal/cmd/organization" @@ -84,6 +86,8 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(config.NewCmd()) cmd.AddCommand(curl.NewCmd()) cmd.AddCommand(dns.NewCmd()) + cmd.AddCommand(logme.NewCmd()) + cmd.AddCommand(mariadb.NewCmd()) cmd.AddCommand(mongodbflex.NewCmd()) cmd.AddCommand(opensearch.NewCmd()) cmd.AddCommand(organization.NewCmd()) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index e9426d9f..a9f93e8d 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -17,6 +17,8 @@ const ( SessionTimeLimitKey = "session_time_limit" DNSCustomEndpointKey = "dns_custom_endpoint" + LogMeCustomEndpointKey = "logme_custom_endpoint" + MariaDBCustomEndpointKey = "mariadb_custom_endpoint" MembershipCustomEndpointKey = "membership_custom_endpoint" MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint" OpenSearchCustomEndpointKey = "opensearch_custom_endpoint" @@ -46,6 +48,8 @@ var ConfigKeys = []string{ SessionTimeLimitKey, DNSCustomEndpointKey, + LogMeCustomEndpointKey, + MariaDBCustomEndpointKey, OpenSearchCustomEndpointKey, PostgresFlexCustomEndpointKey, ResourceManagerEndpointKey, diff --git a/internal/pkg/services/logme/client/client.go b/internal/pkg/services/logme/client/client.go new file mode 100644 index 00000000..5020ba1f --- /dev/null +++ b/internal/pkg/services/logme/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/logme" +) + +func ConfigureClient(cmd *cobra.Command) (*logme.APIClient, error) { + var err error + var apiClient *logme.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.LogMeCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = logme.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/logme/utils/utils.go b/internal/pkg/services/logme/utils/utils.go new file mode 100644 index 00000000..d63b4ea5 --- /dev/null +++ b/internal/pkg/services/logme/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/logme" +) + +const ( + service = "logme" +) + +func ValidatePlanId(planId string, offerings *logme.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 *logme.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 LogMeClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*logme.Instance, error) + GetCredentialsExecute(ctx context.Context, projectId, instanceId, credentialsId string) (*logme.CredentialsResponse, error) +} + +func GetInstanceName(ctx context.Context, apiClient LogMeClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get LogMe instance: %w", err) + } + return *resp.Name, nil +} + +func GetCredentialsUsername(ctx context.Context, apiClient LogMeClient, projectId, instanceId, credentialsId string) (string, error) { + resp, err := apiClient.GetCredentialsExecute(ctx, projectId, instanceId, credentialsId) + if err != nil { + return "", fmt.Errorf("get LogMe credentials: %w", err) + } + return *resp.Raw.Credentials.Username, nil +} diff --git a/internal/pkg/services/logme/utils/utils_test.go b/internal/pkg/services/logme/utils/utils_test.go new file mode 100644 index 00000000..97fde111 --- /dev/null +++ b/internal/pkg/services/logme/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/logme" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testCredentialsId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testCredentialsUsername = "username" +) + +type logMeClientMocked struct { + getInstanceFails bool + getInstanceResp *logme.Instance + getCredentialsFails bool + getCredentialsResp *logme.CredentialsResponse +} + +func (m *logMeClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*logme.Instance, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} + +func (m *logMeClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*logme.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 *logme.Instance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &logme.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 := &logMeClientMocked{ + 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 *logme.CredentialsResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getCredentialsResp: &logme.CredentialsResponse{ + Raw: &logme.RawCredentials{ + Credentials: &logme.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 := &logMeClientMocked{ + 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/mariadb/client/client.go b/internal/pkg/services/mariadb/client/client.go new file mode 100644 index 00000000..dac6c2c5 --- /dev/null +++ b/internal/pkg/services/mariadb/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/mariadb" +) + +func ConfigureClient(cmd *cobra.Command) (*mariadb.APIClient, error) { + var err error + var apiClient *mariadb.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.MariaDBCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = mariadb.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/mariadb/utils/utils.go b/internal/pkg/services/mariadb/utils/utils.go new file mode 100644 index 00000000..44872397 --- /dev/null +++ b/internal/pkg/services/mariadb/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/mariadb" +) + +const ( + service = "mariadb" +) + +func ValidatePlanId(planId string, offerings *mariadb.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 *mariadb.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 MariaDBClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*mariadb.Instance, error) + GetCredentialsExecute(ctx context.Context, projectId, instanceId, credentialsId string) (*mariadb.CredentialsResponse, error) +} + +func GetInstanceName(ctx context.Context, apiClient MariaDBClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get MariaDB instance: %w", err) + } + return *resp.Name, nil +} + +func GetCredentialsUsername(ctx context.Context, apiClient MariaDBClient, projectId, instanceId, credentialsId string) (string, error) { + resp, err := apiClient.GetCredentialsExecute(ctx, projectId, instanceId, credentialsId) + if err != nil { + return "", fmt.Errorf("get MariaDB credentials: %w", err) + } + return *resp.Raw.Credentials.Username, nil +} diff --git a/internal/pkg/services/mariadb/utils/utils_test.go b/internal/pkg/services/mariadb/utils/utils_test.go new file mode 100644 index 00000000..cd4f1a16 --- /dev/null +++ b/internal/pkg/services/mariadb/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/mariadb" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testCredentialsId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testCredentialsUsername = "username" +) + +type mariaDBClientMocked struct { + getInstanceFails bool + getInstanceResp *mariadb.Instance + getCredentialsFails bool + getCredentialsResp *mariadb.CredentialsResponse +} + +func (m *mariaDBClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*mariadb.Instance, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} + +func (m *mariaDBClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*mariadb.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 *mariadb.Instance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &mariadb.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 := &mariaDBClientMocked{ + 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 *mariadb.CredentialsResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getCredentialsResp: &mariadb.CredentialsResponse{ + Raw: &mariadb.RawCredentials{ + Credentials: &mariadb.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 := &mariaDBClientMocked{ + 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) + } + }) + } +}