diff --git a/docs/stackit.md b/docs/stackit.md index d493751b7..5f5c5b0b6 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -31,6 +31,7 @@ stackit [flags] * [stackit config](./stackit_config.md) - Provides functionality for 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 load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer * [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 diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 306ceb45a..faa93fa1a 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -33,6 +33,7 @@ stackit config set [flags] --authorization-custom-endpoint string Authorization API base URL, used in calls to this API --dns-custom-endpoint string DNS API base URL, used in calls to this API -h, --help Help for "stackit config set" + --load-balancer-custom-endpoint string Load Balancer API base URL, used in calls to this API --logme-custom-endpoint string LogMe API base URL, used in calls to this API --mariadb-custom-endpoint string MariaDB API base URL, used in calls to this API --mongodbflex-custom-endpoint string MongoDB Flex API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index d60cbb881..f244d9eec 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -31,6 +31,7 @@ stackit config unset [flags] --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL -h, --help Help for "stackit config unset" + --load-balancer-custom-endpoint Load Balancer API base URL. If unset, uses the default base URL --logme-custom-endpoint LogMe API base URL. If unset, uses the default base URL --mariadb-custom-endpoint MariaDB API base URL. If unset, uses the default base URL --mongodbflex-custom-endpoint MongoDB Flex API base URL. If unset, uses the default base URL diff --git a/docs/stackit_load-balancer.md b/docs/stackit_load-balancer.md new file mode 100644 index 000000000..e4bff615a --- /dev/null +++ b/docs/stackit_load-balancer.md @@ -0,0 +1,41 @@ +## stackit load-balancer + +Provides functionality for Load Balancer + +### Synopsis + +Provides functionality for Load Balancer. + +``` +stackit load-balancer [flags] +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit load-balancer create](./stackit_load-balancer_create.md) - Creates a Load Balancer +* [stackit load-balancer delete](./stackit_load-balancer_delete.md) - Deletes a Load Balancer +* [stackit load-balancer describe](./stackit_load-balancer_describe.md) - Shows details of a Load Balancer +* [stackit load-balancer generate-payload](./stackit_load-balancer_generate-payload.md) - Generates a payload to create/update a Load Balancer +* [stackit load-balancer list](./stackit_load-balancer_list.md) - Lists all Load Balancers +* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials +* [stackit load-balancer quota](./stackit_load-balancer_quota.md) - Shows the configured Load Balancer quota +* [stackit load-balancer target-pool](./stackit_load-balancer_target-pool.md) - Provides functionality for target pools +* [stackit load-balancer update](./stackit_load-balancer_update.md) - Updates a Load Balancer + diff --git a/docs/stackit_load-balancer_create.md b/docs/stackit_load-balancer_create.md new file mode 100644 index 000000000..b685dd6e7 --- /dev/null +++ b/docs/stackit_load-balancer_create.md @@ -0,0 +1,50 @@ +## stackit load-balancer create + +Creates a Load Balancer + +### Synopsis + +Creates a Load Balancer. +The payload can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/load-balancer/version/v1#tag/Load-Balancer/operation/APIService_CreateLoadBalancer for information regarding the payload structure. + +``` +stackit load-balancer create [flags] +``` + +### Examples + +``` + Create a load balancer using an API payload sourced from the file "./payload.json" + $ stackit load-balancer create --payload @./payload.json + + Create a load balancer using an API payload provided as a JSON string + $ stackit load-balancer create --payload "{...}" + + Generate a payload with default values, and adapt it with custom values for the different configuration options + $ stackit load-balancer generate-payload > ./payload.json + + $ stackit load-balancer create --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer create" + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/docs/stackit_load-balancer_delete.md b/docs/stackit_load-balancer_delete.md new file mode 100644 index 000000000..3dd43b2ae --- /dev/null +++ b/docs/stackit_load-balancer_delete.md @@ -0,0 +1,39 @@ +## stackit load-balancer delete + +Deletes a Load Balancer + +### Synopsis + +Deletes a Load Balancer. + +``` +stackit load-balancer delete LOAD_BALANCER_NAME [flags] +``` + +### Examples + +``` + Deletes a load balancer with name "my-load-balancer" + $ stackit load-balancer delete my-load-balancer +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/docs/stackit_load-balancer_describe.md b/docs/stackit_load-balancer_describe.md new file mode 100644 index 000000000..f3b41e9ce --- /dev/null +++ b/docs/stackit_load-balancer_describe.md @@ -0,0 +1,42 @@ +## stackit load-balancer describe + +Shows details of a Load Balancer + +### Synopsis + +Shows details of a Load Balancer. + +``` +stackit load-balancer describe LOAD_BALANCER_NAME [flags] +``` + +### Examples + +``` + Get details of a load balancer with name "my-load-balancer" + $ stackit load-balancer describe my-load-balancer + + Get details of a load-balancer with name "my-load-balancer" in a JSON format + $ stackit load-balancer describe my-load-balancer --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/docs/stackit_load-balancer_generate-payload.md b/docs/stackit_load-balancer_generate-payload.md new file mode 100644 index 000000000..f01579eca --- /dev/null +++ b/docs/stackit_load-balancer_generate-payload.md @@ -0,0 +1,48 @@ +## stackit load-balancer generate-payload + +Generates a payload to create/update a Load Balancer + +### Synopsis + +Generates a JSON payload with values to be used as --payload input for load balancer creation or update. +See https://docs.api.stackit.cloud/documentation/load-balancer/version/v1#tag/Load-Balancer/operation/APIService_CreateLoadBalancer for information regarding the payload structure. + +``` +stackit load-balancer generate-payload [flags] +``` + +### Examples + +``` + Generate a payload, and adapt it with custom values for the different configuration options + $ stackit load-balancer generate-payload > ./payload.json + + $ stackit load-balancer create --payload @./payload.json + + Generate a payload with values of an existing load balancer, and adapt it with custom values for the different configuration options + $ stackit load-balancer generate-payload --lb-name xxx > ./payload.json + + $ stackit load-balancer update xxx --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer generate-payload" + -n, --lb-name string If set, generates the payload with the current values of the given load balancer. If unset, generates the payload with empty values +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/docs/stackit_load-balancer_list.md b/docs/stackit_load-balancer_list.md new file mode 100644 index 000000000..e4525cff4 --- /dev/null +++ b/docs/stackit_load-balancer_list.md @@ -0,0 +1,46 @@ +## stackit load-balancer list + +Lists all Load Balancers + +### Synopsis + +Lists all Load Balancers. + +``` +stackit load-balancer list [flags] +``` + +### Examples + +``` + List all load balancers + $ stackit load-balancer list + + List all loadbalancers in JSON format + $ stackit load-balancer list --output-format json + + List up to 10 load balancers + $ stackit load-balancer list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/docs/stackit_load-balancer_observability-credentials.md b/docs/stackit_load-balancer_observability-credentials.md new file mode 100644 index 000000000..111523c5f --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials.md @@ -0,0 +1,37 @@ +## stackit load-balancer observability-credentials + +Provides functionality for Load Balancer observability credentials + +### Synopsis + +Provides functionality for Load Balancer observability credentials. These commands can be used to store and update existing credentials, which are valid to be used for Load Balancer observability. This means, e.g. when using Argus, first of all these credentials must be created for that Argus instance (by using "stackit argus credentials create") and then can be managed for a Load Balancer by using the commands in this group. + +``` +stackit load-balancer observability-credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer observability-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" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer +* [stackit load-balancer observability-credentials add](./stackit_load-balancer_observability-credentials_add.md) - Adds observability credentials to Load Balancer +* [stackit load-balancer observability-credentials delete](./stackit_load-balancer_observability-credentials_delete.md) - Deletes observability credentials for Load Balancer +* [stackit load-balancer observability-credentials describe](./stackit_load-balancer_observability-credentials_describe.md) - Shows details of observability credentials for Load Balancer +* [stackit load-balancer observability-credentials list](./stackit_load-balancer_observability-credentials_list.md) - Lists all observability credentials for Load Balancer +* [stackit load-balancer observability-credentials update](./stackit_load-balancer_observability-credentials_update.md) - Updates observability credentials for Load Balancer + diff --git a/docs/stackit_load-balancer_observability-credentials_add.md b/docs/stackit_load-balancer_observability-credentials_add.md new file mode 100644 index 000000000..f66ccf0e6 --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials_add.md @@ -0,0 +1,45 @@ +## stackit load-balancer observability-credentials add + +Adds observability credentials to Load Balancer + +### Synopsis + +Adds existing observability credentials (username and password) to Load Balancer. The credentials can be for Argus or another monitoring tool. + +``` +stackit load-balancer observability-credentials add [flags] +``` + +### Examples + +``` + Add observability credentials to a load balancer with username "xxx" and display name "yyy". The password is entered using the terminal + $ stackit load-balancer observability-credentials add --username xxx --display-name yyy + + Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag + $ stackit load-balancer observability-credentials add --username xxx --password @./password.txt --display-name yyy +``` + +### Options + +``` + --display-name string Credentials display name + -h, --help Help for "stackit load-balancer observability-credentials add" + --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt). + --username string Username +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials + diff --git a/docs/stackit_load-balancer_observability-credentials_delete.md b/docs/stackit_load-balancer_observability-credentials_delete.md new file mode 100644 index 000000000..c58199974 --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials_delete.md @@ -0,0 +1,39 @@ +## stackit load-balancer observability-credentials delete + +Deletes observability credentials for Load Balancer + +### Synopsis + +Deletes observability credentials for Load Balancer. + +``` +stackit load-balancer observability-credentials delete CREDENTIALS_REF [flags] +``` + +### Examples + +``` + Delete observability credentials with reference "credentials-xxx" for Load Balancer + $ stackit load-balancer observability-credentials delete credentials-xxx +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer observability-credentials delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials + diff --git a/docs/stackit_load-balancer_observability-credentials_describe.md b/docs/stackit_load-balancer_observability-credentials_describe.md new file mode 100644 index 000000000..cf0d63e59 --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials_describe.md @@ -0,0 +1,39 @@ +## stackit load-balancer observability-credentials describe + +Shows details of observability credentials for Load Balancer + +### Synopsis + +Shows details of observability credentials for Load Balancer. + +``` +stackit load-balancer observability-credentials describe CREDENTIALS_REF [flags] +``` + +### Examples + +``` + Get details of observability credentials with reference "credentials-xxx" + $ stackit load-balancer observability-credentials describe credentials-xxx +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer observability-credentials describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials + diff --git a/docs/stackit_load-balancer_observability-credentials_list.md b/docs/stackit_load-balancer_observability-credentials_list.md new file mode 100644 index 000000000..ee183e01f --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials_list.md @@ -0,0 +1,46 @@ +## stackit load-balancer observability-credentials list + +Lists all observability credentials for Load Balancer + +### Synopsis + +Lists all observability credentials for Load Balancer. + +``` +stackit load-balancer observability-credentials list [flags] +``` + +### Examples + +``` + List all observability credentials for Load Balancer + $ stackit load-balancer observability-credentials list + + List all observability credentials for Load Balancer in JSON format + $ stackit load-balancer observability-credentials list --output-format json + + List up to 10 observability credentials for Load Balancer + $ stackit load-balancer observability-credentials list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer observability-credentials list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials + diff --git a/docs/stackit_load-balancer_observability-credentials_update.md b/docs/stackit_load-balancer_observability-credentials_update.md new file mode 100644 index 000000000..86646227e --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials_update.md @@ -0,0 +1,45 @@ +## stackit load-balancer observability-credentials update + +Updates observability credentials for Load Balancer + +### Synopsis + +Updates existing observability credentials (username and password) for Load Balancer. The credentials can be for Argus or another monitoring tool. + +``` +stackit load-balancer observability-credentials update [flags] +``` + +### Examples + +``` + Update the password and username of observability credentials of Load Balancer with credentials reference "credentials-xxx". The password is entered using the terminal + $ stackit load-balancer observability-credentials update credentials-xxx --username new-username + + Update the password of observability credentials of Load Balancer with credentials reference "credentials-xxx", by providing the path to a file with the new password as flag + $ stackit load-balancer observability-credentials update credentials-xxx --password @./new-password.txt +``` + +### Options + +``` + --display-name string Credentials name + -h, --help Help for "stackit load-balancer observability-credentials update" + --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt). + --username string Username +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials + diff --git a/docs/stackit_load-balancer_quota.md b/docs/stackit_load-balancer_quota.md new file mode 100644 index 000000000..b81c73575 --- /dev/null +++ b/docs/stackit_load-balancer_quota.md @@ -0,0 +1,39 @@ +## stackit load-balancer quota + +Shows the configured Load Balancer quota + +### Synopsis + +Shows the configured Load Balancer quota for the project. If you want to change the quota, please create a support ticket in the STACKIT Help Center (https://support.stackit.cloud) + +``` +stackit load-balancer quota [flags] +``` + +### Examples + +``` + Get the configured load balancer quota for the project + $ stackit load-balancer quota +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer quota" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/docs/stackit_load-balancer_target-pool.md b/docs/stackit_load-balancer_target-pool.md new file mode 100644 index 000000000..92f97009f --- /dev/null +++ b/docs/stackit_load-balancer_target-pool.md @@ -0,0 +1,35 @@ +## stackit load-balancer target-pool + +Provides functionality for target pools + +### Synopsis + +Provides functionality for target pools. + +``` +stackit load-balancer target-pool [flags] +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer target-pool" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer +* [stackit load-balancer target-pool add-target](./stackit_load-balancer_target-pool_add-target.md) - Adds a target to a target pool +* [stackit load-balancer target-pool describe](./stackit_load-balancer_target-pool_describe.md) - Shows details of a target pool in a Load Balancer +* [stackit load-balancer target-pool remove-target](./stackit_load-balancer_target-pool_remove-target.md) - Removes a target from a target pool + diff --git a/docs/stackit_load-balancer_target-pool_add-target.md b/docs/stackit_load-balancer_target-pool_add-target.md new file mode 100644 index 000000000..ba18837e5 --- /dev/null +++ b/docs/stackit_load-balancer_target-pool_add-target.md @@ -0,0 +1,43 @@ +## stackit load-balancer target-pool add-target + +Adds a target to a target pool + +### Synopsis + +Adds a target to a target pool. +The target IP must by unique within a target pool and must be a valid IPv4 or IPv6. + +``` +stackit load-balancer target-pool add-target TARGET_IP [flags] +``` + +### Examples + +``` + Add a target with IP 1.2.3.4 and name "my-new-target" to target pool "my-target-pool" of load balancer with name "my-load-balancer" + $ stackit load-balancer target-pool add-target 1.2.3.4 --target-name my-new-target --target-pool-name my-target-pool --lb-name my-load-balancer +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer target-pool add-target" + --lb-name string Load balancer name + -n, --target-name string Target name + --target-pool-name string Target pool name +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer target-pool](./stackit_load-balancer_target-pool.md) - Provides functionality for target pools + diff --git a/docs/stackit_load-balancer_target-pool_describe.md b/docs/stackit_load-balancer_target-pool_describe.md new file mode 100644 index 000000000..02a409205 --- /dev/null +++ b/docs/stackit_load-balancer_target-pool_describe.md @@ -0,0 +1,43 @@ +## stackit load-balancer target-pool describe + +Shows details of a target pool in a Load Balancer + +### Synopsis + +Shows details of a target pool in a Load Balancer. + +``` +stackit load-balancer target-pool describe TARGET_POOL_NAME [flags] +``` + +### Examples + +``` + Get details of a target pool with name "pool" in load balancer with name "my-load-balancer" + $ stackit load-balancer target-pool describe pool --lb-name my-load-balancer + + Get details of a target pool with name "pool" in load balancer with name "my-load-balancer in JSON output" + $ stackit load-balancer target-pool describe pool --lb-name my-load-balancer --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer target-pool describe" + --lb-name string Name of the load balancer +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer target-pool](./stackit_load-balancer_target-pool.md) - Provides functionality for target pools + diff --git a/docs/stackit_load-balancer_target-pool_remove-target.md b/docs/stackit_load-balancer_target-pool_remove-target.md new file mode 100644 index 000000000..a910fb3a9 --- /dev/null +++ b/docs/stackit_load-balancer_target-pool_remove-target.md @@ -0,0 +1,41 @@ +## stackit load-balancer target-pool remove-target + +Removes a target from a target pool + +### Synopsis + +Removes a target from a target pool. + +``` +stackit load-balancer target-pool remove-target TARGET_IP [flags] +``` + +### Examples + +``` + Remove target with IP 1.2.3.4 from target pool "my-target-pool" of load balancer with name "my-load-balancer" + $ stackit load-balancer target-pool remove-target 1.2.3.4 --target-pool-name my-target-pool --lb-name my-load-balancer +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer target-pool remove-target" + --lb-name string Load balancer name + --target-pool-name string Target IP of the target to remove. Must be a valid IPv4 or IPv6 +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer target-pool](./stackit_load-balancer_target-pool.md) - Provides functionality for target pools + diff --git a/docs/stackit_load-balancer_update.md b/docs/stackit_load-balancer_update.md new file mode 100644 index 000000000..97eeddb81 --- /dev/null +++ b/docs/stackit_load-balancer_update.md @@ -0,0 +1,50 @@ +## stackit load-balancer update + +Updates a Load Balancer + +### Synopsis + +Updates a load balancer. +The payload can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/load-balancer/version/v1#tag/Load-Balancer/operation/APIService_UpdateLoadBalancer for information regarding the payload structure. + +``` +stackit load-balancer update LOAD_BALANCER_NAME [flags] +``` + +### Examples + +``` + Update a load balancer with name "xxx", using an API payload sourced from the file "./payload.json" + $ stackit load-balancer update xxx --payload @./payload.json + + Update a load balancer with name "xxx", using an API payload provided as a JSON string + $ stackit load-balancer update xxx --payload "{...}" + + Generate a payload with the current values of an existing load balancer xxx, and adapt it with custom values for the different configuration options + $ stackit load-balancer generate-payload --lb-name xxx > ./payload.json + + $ stackit load-balancer update xxx --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer update" + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer + diff --git a/go.mod b/go.mod index 5d6cc1a48..be43f1a01 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/zalando/go-keyring v0.2.4 golang.org/x/mod v0.17.0 golang.org/x/oauth2 v0.20.0 + golang.org/x/term v0.17.0 golang.org/x/text v0.15.0 ) @@ -53,6 +54,7 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/stackitcloud/stackit-sdk-go/services/argus v0.10.0 + github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.9.0 @@ -61,7 +63,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect - golang.org/x/sys v0.17.0 // indirect + golang.org/x/sys v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 3b63cb753..a45e3be91 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.2.0 h1:vdv8DiG9 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.2.0/go.mod h1:1sLuXa7Qvp9f+wKWdRjyNe8B2F8JX7nSTd8fBKadri4= github.com/stackitcloud/stackit-sdk-go/services/dns v0.9.1 h1:pj2nAJvgzFSckA56rCPdi7StXGrr06go8qejI1weNJ8= github.com/stackitcloud/stackit-sdk-go/services/dns v0.9.1/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0 h1:LAteZO46XmqTsmPw0QV8n8WiGM205pxrcqHqWznNmyY= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.12.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w= github.com/stackitcloud/stackit-sdk-go/services/logme v0.13.0 h1:/wqs+pfHSjFWTakJVQGD/KwArxmFN8qdYrJDUgA1Gxw= github.com/stackitcloud/stackit-sdk-go/services/logme v0.13.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.13.0 h1:qioXHNeQ2xdwT5iGOk0c3hQIpct7zlAYzLRqXvsJ7CA= @@ -130,8 +132,10 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 127e1039c..d551face9 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -22,6 +22,7 @@ const ( argusCustomEndpointFlag = "argus-custom-endpoint" authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" + loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" @@ -125,6 +126,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(argusCustomEndpointFlag, "", "Argus API base URL, used in calls to this API") cmd.Flags().String(authorizationCustomEndpointFlag, "", "Authorization API base URL, used in calls to this API") cmd.Flags().String(dnsCustomEndpointFlag, "", "DNS API base URL, used in calls to this API") + cmd.Flags().String(loadBalancerCustomEndpointFlag, "", "Load Balancer API base URL, used in calls to this API") cmd.Flags().String(logMeCustomEndpointFlag, "", "LogMe API base URL, used in calls to this API") cmd.Flags().String(mariaDBCustomEndpointFlag, "", "MariaDB API base URL, used in calls to this API") cmd.Flags().String(mongoDBFlexCustomEndpointFlag, "", "MongoDB Flex API base URL, used in calls to this API") @@ -144,6 +146,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.DNSCustomEndpointKey, cmd.Flags().Lookup(dnsCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.LoadBalancerCustomEndpointKey, cmd.Flags().Lookup(loadBalancerCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.LogMeCustomEndpointKey, cmd.Flags().Lookup(logMeCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.MariaDBCustomEndpointKey, cmd.Flags().Lookup(mariaDBCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 99cf79f7e..e802a6500 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -25,6 +25,7 @@ const ( argusCustomEndpointFlag = "argus-custom-endpoint" authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" + loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" @@ -49,6 +50,7 @@ type inputModel struct { ArgusCustomEndpoint bool AuthorizationCustomEndpoint bool DNSCustomEndpoint bool + LoadBalancerCustomEndpoint bool LogMeCustomEndpoint bool MariaDBCustomEndpoint bool MongoDBFlexCustomEndpoint bool @@ -108,6 +110,9 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.DNSCustomEndpoint { viper.Set(config.DNSCustomEndpointKey, "") } + if model.LoadBalancerCustomEndpoint { + viper.Set(config.LoadBalancerCustomEndpointKey, "") + } if model.LogMeCustomEndpoint { viper.Set(config.LogMeCustomEndpointKey, "") } @@ -166,6 +171,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(argusCustomEndpointFlag, false, "Argus API base URL. If unset, uses the default base URL") cmd.Flags().Bool(authorizationCustomEndpointFlag, false, "Authorization API base URL. If unset, uses the default base URL") cmd.Flags().Bool(dnsCustomEndpointFlag, false, "DNS API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(loadBalancerCustomEndpointFlag, false, "Load Balancer API base URL. If unset, uses the default base URL") cmd.Flags().Bool(logMeCustomEndpointFlag, false, "LogMe API base URL. If unset, uses the default base URL") cmd.Flags().Bool(mariaDBCustomEndpointFlag, false, "MariaDB API base URL. If unset, uses the default base URL") cmd.Flags().Bool(mongoDBFlexCustomEndpointFlag, false, "MongoDB Flex API base URL. If unset, uses the default base URL") @@ -191,6 +197,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { ArgusCustomEndpoint: flags.FlagToBoolValue(p, cmd, argusCustomEndpointFlag), AuthorizationCustomEndpoint: flags.FlagToBoolValue(p, cmd, authorizationCustomEndpointFlag), DNSCustomEndpoint: flags.FlagToBoolValue(p, cmd, dnsCustomEndpointFlag), + LoadBalancerCustomEndpoint: flags.FlagToBoolValue(p, cmd, loadBalancerCustomEndpointFlag), LogMeCustomEndpoint: flags.FlagToBoolValue(p, cmd, logMeCustomEndpointFlag), MariaDBCustomEndpoint: flags.FlagToBoolValue(p, cmd, mariaDBCustomEndpointFlag), MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, mongoDBFlexCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index fd51570f7..7e9937d0e 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -20,6 +20,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool argusCustomEndpointFlag: true, authorizationCustomEndpointFlag: true, dnsCustomEndpointFlag: true, + loadBalancerCustomEndpointFlag: true, logMeCustomEndpointFlag: true, mariaDBCustomEndpointFlag: true, objectStorageCustomEndpointFlag: true, @@ -48,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { ArgusCustomEndpoint: true, AuthorizationCustomEndpoint: true, DNSCustomEndpoint: true, + LoadBalancerCustomEndpoint: true, LogMeCustomEndpoint: true, MariaDBCustomEndpoint: true, ObjectStorageCustomEndpoint: true, @@ -92,6 +94,7 @@ func TestParseInput(t *testing.T) { model.ArgusCustomEndpoint = false model.AuthorizationCustomEndpoint = false model.DNSCustomEndpoint = false + model.LoadBalancerCustomEndpoint = false model.LogMeCustomEndpoint = false model.MariaDBCustomEndpoint = false model.ObjectStorageCustomEndpoint = false diff --git a/internal/cmd/load-balancer/create/create.go b/internal/cmd/load-balancer/create/create.go new file mode 100644 index 000000000..324948fff --- /dev/null +++ b/internal/cmd/load-balancer/create/create.go @@ -0,0 +1,164 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer/wait" +) + +const ( + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Payload *loadbalancer.CreateLoadBalancerPayload +} + +var ( + xRequestId = uuid.NewString() +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Load Balancer", + Long: fmt.Sprintf("%s\n%s\n%s", + "Creates a Load Balancer.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/load-balancer/version/v1#tag/Load-Balancer/operation/APIService_CreateLoadBalancer for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a load balancer using an API payload sourced from the file "./payload.json"`, + "$ stackit load-balancer create --payload @./payload.json"), + examples.NewExample( + `Create a load balancer using an API payload provided as a JSON string`, + `$ stackit load-balancer create --payload "{...}"`), + examples.NewExample( + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit load-balancer generate-payload > ./payload.json`, + ``, + `$ stackit load-balancer create --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a load balancer for project %q?", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("create load balancer: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Creating load balancer") + _, err = wait.CreateLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, *model.Payload.Name).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for load balancer creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s load balancer with name %q \n", operationState, *model.Payload.Name) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json).`) + + err := flags.MarkFlagsRequired(cmd, payloadFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *loadbalancer.CreateLoadBalancerPayload + if payloadValue != nil { + payload = &loadbalancer.CreateLoadBalancerPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Payload: payload, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiCreateLoadBalancerRequest { + req := apiClient.CreateLoadBalancer(ctx, model.ProjectId) + req = req.CreateLoadBalancerPayload(*model.Payload) + req = req.XRequestID(xRequestId) + return req +} diff --git a/internal/cmd/load-balancer/create/create_test.go b/internal/cmd/load-balancer/create/create_test.go new file mode 100644 index 000000000..09dae8cc1 --- /dev/null +++ b/internal/cmd/load-balancer/create/create_test.go @@ -0,0 +1,354 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "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" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() +var testRequestId = xRequestId + +var testPayload = &loadbalancer.CreateLoadBalancerPayload{ + ExternalAddress: utils.Ptr(""), + + Listeners: &[]loadbalancer.Listener{ + { + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + }, + }, + Name: utils.Ptr(""), + Networks: &[]loadbalancer.Network{ + { + NetworkId: utils.Ptr(""), + Role: utils.Ptr(""), + }, + }, + Options: &loadbalancer.LoadBalancerOptions{ + AccessControl: &loadbalancer.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: &[]string{ + "", + }, + }, + EphemeralAddress: utils.Ptr(false), + Observability: &loadbalancer.LoadbalancerOptionObservability{ + Logs: &loadbalancer.LoadbalancerOptionLogs{ + CredentialsRef: utils.Ptr(""), + PushUrl: utils.Ptr(""), + }, + Metrics: &loadbalancer.LoadbalancerOptionMetrics{ + CredentialsRef: utils.Ptr(""), + PushUrl: utils.Ptr(""), + }, + }, + PrivateNetworkOnly: utils.Ptr(false), + }, + TargetPools: &[]loadbalancer.TargetPool{ + { + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + HealthyThreshold: utils.Ptr(int64(0)), + Interval: utils.Ptr(""), + IntervalJitter: utils.Ptr(""), + Timeout: utils.Ptr(""), + UnhealthyThreshold: utils.Ptr(int64(0)), + }, + Name: utils.Ptr(""), + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(false), + }, + TargetPort: utils.Ptr(int64(0)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(""), + Ip: utils.Ptr(""), + }, + }, + }, + }, +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + payloadFlag: `{ + "externalAddress": "", + "listeners": [ + { + "displayName": "", + "port": 0, + "protocol": "", + "serverNameIndicators": [ + { + "name": "" + } + ], + "targetPool": "", + "tcp": { + "idleTimeout": "" + }, + "udp": { + "idleTimeout": "" + } + } + ], + "name": "", + "networks": [ + { + "networkId": "", + "role": "" + } + ], + "options": { + "accessControl": { + "allowedSourceRanges": [ + "" + ] + }, + "ephemeralAddress": false, + "observability": { + "logs": { + "credentialsRef": "", + "pushUrl": "" + }, + "metrics": { + "credentialsRef": "", + "pushUrl": "" + } + }, + "privateNetworkOnly": false + }, + "targetPools": [ + { + "activeHealthCheck": { + "healthyThreshold": 0, + "interval": "", + "intervalJitter": "", + "timeout": "", + "unhealthyThreshold": 0 + }, + "name": "", + "sessionPersistence": { + "useSourceIpAddress": false + }, + "targetPort": 0, + "targets": [ + { + "displayName": "", + "ip": "" + } + ] + } + ] +}`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiCreateLoadBalancerRequest)) loadbalancer.ApiCreateLoadBalancerRequest { + request := testClient.CreateLoadBalancer(testCtx, testProjectId) + request = request.CreateLoadBalancerPayload(*testPayload) + request = request.XRequestID(testRequestId) + 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: "no flag 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: "payload is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: false, + }, + { + description: "payload is empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "" + }), + isValid: false, + }, + { + description: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, 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, + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest loadbalancer.ApiCreateLoadBalancerRequest + isValid bool + }{ + { + 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/load-balancer/delete/delete.go b/internal/cmd/load-balancer/delete/delete.go new file mode 100644 index 000000000..ff3f50e80 --- /dev/null +++ b/internal/cmd/load-balancer/delete/delete.go @@ -0,0 +1,117 @@ +package delete + +import ( + "context" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer/wait" +) + +const ( + loadBalancerNameArg = "LOAD_BALANCER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LoadBalancerName string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", loadBalancerNameArg), + Short: "Deletes a Load Balancer", + Long: "Deletes a Load Balancer.", + Args: args.SingleArg(loadBalancerNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Deletes a load balancer with name "my-load-balancer"`, + "$ stackit load-balancer delete my-load-balancer"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete load balancer %q? (This cannot be undone)", model.LoadBalancerName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete load balancer: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Deleting load balancer") + _, err = wait.DeleteLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.LoadBalancerName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for load balancer deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Info("%s load balancer %q\n", operationState, model.LoadBalancerName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + loadBalancerName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LoadBalancerName: loadBalancerName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiDeleteLoadBalancerRequest { + req := apiClient.DeleteLoadBalancer(ctx, model.ProjectId, model.LoadBalancerName) + return req +} diff --git a/internal/cmd/load-balancer/delete/delete_test.go b/internal/cmd/load-balancer/delete/delete_test.go new file mode 100644 index 000000000..eac37e0fa --- /dev/null +++ b/internal/cmd/load-balancer/delete/delete_test.go @@ -0,0 +1,208 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() +var testLoadBalancerName = "loadBalancer" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testLoadBalancerName, + } + 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, + Verbosity: globalflags.VerbosityDefault, + }, + LoadBalancerName: testLoadBalancerName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiDeleteLoadBalancerRequest)) loadbalancer.ApiDeleteLoadBalancerRequest { + request := testClient.DeleteLoadBalancer(testCtx, testProjectId, testLoadBalancerName) + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 + isValid bool + expectedRequest loadbalancer.ApiDeleteLoadBalancerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + 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/load-balancer/describe/describe.go b/internal/cmd/load-balancer/describe/describe.go new file mode 100644 index 000000000..fe28b86d7 --- /dev/null +++ b/internal/cmd/load-balancer/describe/describe.go @@ -0,0 +1,200 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + loadBalancerNameArg = "LOAD_BALANCER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LoadBalancerName string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", loadBalancerNameArg), + Short: "Shows details of a Load Balancer", + Long: "Shows details of a Load Balancer.", + Args: args.SingleArg(loadBalancerNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of a load balancer with name "my-load-balancer"`, + "$ stackit load-balancer describe my-load-balancer"), + examples.NewExample( + `Get details of a load-balancer with name "my-load-balancer" in a JSON format`, + "$ stackit load-balancer describe my-load-balancer --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read load balancer: %w", err) + } + + return outputResult(p, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + loadBalancerName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LoadBalancerName: loadBalancerName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest { + req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.LoadBalancerName) + return req +} + +func outputResult(p *print.Printer, outputFormat string, loadBalancer *loadbalancer.LoadBalancer) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(loadBalancer, "", " ") + if err != nil { + return fmt.Errorf("marshal load balancer: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + return outputResultAsTable(p, loadBalancer) + } +} + +func outputResultAsTable(p *print.Printer, loadBalancer *loadbalancer.LoadBalancer) error { + content := renderLoadBalancer(loadBalancer) + + if loadBalancer.Listeners != nil { + content += renderListeners(*loadBalancer.Listeners) + } + + if loadBalancer.TargetPools != nil { + content += renderTargetPools(*loadBalancer.TargetPools) + } + + err := p.PagerDisplay(content) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + + return nil +} + +func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string { + acl := []string{} + privateAccessOnly := false + if loadBalancer.Options != nil { + if loadBalancer.Options.AccessControl != nil && loadBalancer.Options.AccessControl.AllowedSourceRanges != nil { + acl = *loadBalancer.Options.AccessControl.AllowedSourceRanges + } + + if loadBalancer.Options.PrivateNetworkOnly != nil { + privateAccessOnly = *loadBalancer.Options.PrivateNetworkOnly + } + } + + networkId := "-" + if loadBalancer.Networks != nil && len(*loadBalancer.Networks) > 0 { + networks := *loadBalancer.Networks + networkId = *networks[0].NetworkId + } + + externalAdress := "-" + if loadBalancer.ExternalAddress != nil { + externalAdress = *loadBalancer.ExternalAddress + } + + errorDescriptions := []string{} + if loadBalancer.Errors != nil && len((*loadBalancer.Errors)) > 0 { + for _, err := range *loadBalancer.Errors { + errorDescriptions = append(errorDescriptions, *err.Description) + } + } + + table := tables.NewTable() + table.AddRow("NAME", *loadBalancer.Name) + table.AddSeparator() + table.AddRow("STATE", *loadBalancer.Status) + table.AddSeparator() + if len(errorDescriptions) > 0 { + table.AddRow("ERROR DESCRIPTIONS", strings.Join(errorDescriptions, "\n")) + table.AddSeparator() + } + table.AddRow("PRIVATE ACCESS ONLY", privateAccessOnly) + table.AddSeparator() + table.AddRow("ATTACHED PUBLIC IP", externalAdress) + table.AddSeparator() + table.AddRow("ATTACHED NETWORK ID", networkId) + table.AddSeparator() + table.AddRow("ACL", acl) + return table.Render() +} + +func renderListeners(listeners []loadbalancer.Listener) string { + table := tables.NewTable() + table.SetHeader("LISTENER NAME", "PORT", "PROTOCOL", "TARGET POOL") + for i := range listeners { + listener := listeners[i] + table.AddRow(*listener.Name, *listener.Port, *listener.Protocol, *listener.TargetPool) + } + return table.Render() +} + +func renderTargetPools(targetPools []loadbalancer.TargetPool) string { + table := tables.NewTable() + table.SetHeader("TARGET POOL NAME", "PORT", "TARGETS") + for _, targetPool := range targetPools { + table.AddRow(*targetPool.Name, *targetPool.TargetPort, len(*targetPool.Targets)) + } + return table.Render() +} diff --git a/internal/cmd/load-balancer/describe/describe_test.go b/internal/cmd/load-balancer/describe/describe_test.go new file mode 100644 index 000000000..33960ecca --- /dev/null +++ b/internal/cmd/load-balancer/describe/describe_test.go @@ -0,0 +1,208 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() +var testloadBalancerName = "loadBalancer" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testloadBalancerName, + } + 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, + Verbosity: globalflags.VerbosityDefault, + }, + LoadBalancerName: testloadBalancerName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest)) loadbalancer.ApiGetLoadBalancerRequest { + request := testClient.GetLoadBalancer(testCtx, testProjectId, testloadBalancerName) + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 + isValid bool + expectedRequest loadbalancer.ApiGetLoadBalancerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + 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/load-balancer/generate-payload/generate_payload.go b/internal/cmd/load-balancer/generate-payload/generate_payload.go new file mode 100644 index 000000000..6275ba819 --- /dev/null +++ b/internal/cmd/load-balancer/generate-payload/generate_payload.go @@ -0,0 +1,235 @@ +package generatepayload + +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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/spf13/cobra" +) + +const ( + loadBalancerNameFlag = "lb-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LoadBalancerName *string +} + +var ( + defaultPayloadListener = &loadbalancer.Listener{ + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + } + + defaultPayloadNetwork = &loadbalancer.Network{ + NetworkId: utils.Ptr(""), + Role: utils.Ptr(""), + } + + defaultPayloadTargetPool = &loadbalancer.TargetPool{ + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + HealthyThreshold: utils.Ptr(int64(0)), + Interval: utils.Ptr(""), + IntervalJitter: utils.Ptr(""), + Timeout: utils.Ptr(""), + UnhealthyThreshold: utils.Ptr(int64(0)), + }, + Name: utils.Ptr(""), + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(false), + }, + TargetPort: utils.Ptr(int64(0)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(""), + Ip: utils.Ptr(""), + }, + }, + } + + DefaultCreateLoadBalancerPayload = loadbalancer.CreateLoadBalancerPayload{ + ExternalAddress: utils.Ptr(""), + Listeners: &[]loadbalancer.Listener{ + *defaultPayloadListener, + }, + Name: utils.Ptr(""), + Networks: &[]loadbalancer.Network{ + *defaultPayloadNetwork, + }, + Options: &loadbalancer.LoadBalancerOptions{ + AccessControl: &loadbalancer.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: &[]string{ + "", + }, + }, + EphemeralAddress: utils.Ptr(false), + Observability: &loadbalancer.LoadbalancerOptionObservability{ + Logs: &loadbalancer.LoadbalancerOptionLogs{ + CredentialsRef: utils.Ptr(""), + PushUrl: utils.Ptr(""), + }, + Metrics: &loadbalancer.LoadbalancerOptionMetrics{ + CredentialsRef: utils.Ptr(""), + PushUrl: utils.Ptr(""), + }, + }, + PrivateNetworkOnly: utils.Ptr(false), + }, + TargetPools: &[]loadbalancer.TargetPool{ + *defaultPayloadTargetPool, + }, + } +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to create/update a Load Balancer", + Long: fmt.Sprintf("%s\n%s", + "Generates a JSON payload with values to be used as --payload input for load balancer creation or update.", + "See https://docs.api.stackit.cloud/documentation/load-balancer/version/v1#tag/Load-Balancer/operation/APIService_CreateLoadBalancer for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a payload, and adapt it with custom values for the different configuration options`, + `$ stackit load-balancer generate-payload > ./payload.json`, + ``, + `$ stackit load-balancer create --payload @./payload.json`), + examples.NewExample( + `Generate a payload with values of an existing load balancer, and adapt it with custom values for the different configuration options`, + `$ stackit load-balancer generate-payload --lb-name xxx > ./payload.json`, + ``, + `$ stackit load-balancer update xxx --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if model.LoadBalancerName == nil { + createPayload := DefaultCreateLoadBalancerPayload + return outputCreateResult(p, &createPayload) + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read load balancer: %w", err) + } + + listeners := modifyListener(resp) + + updatePayload := &loadbalancer.UpdateLoadBalancerPayload{ + ExternalAddress: resp.ExternalAddress, + Listeners: listeners, + Name: resp.Name, + Networks: resp.Networks, + Options: resp.Options, + TargetPools: resp.TargetPools, + Version: resp.Version, + } + return outputUpdateResult(p, updatePayload) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(loadBalancerNameFlag, "n", "", "If set, generates the payload with the current values of the given load balancer. If unset, generates the payload with empty values") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + loadBalancerName := flags.FlagToStringPointer(p, cmd, loadBalancerNameFlag) + // If load balancer name is provided, projectId is needed as well + if loadBalancerName != nil && globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LoadBalancerName: loadBalancerName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest { + req := apiClient.GetLoadBalancer(ctx, model.ProjectId, *model.LoadBalancerName) + return req +} + +func outputCreateResult(p *print.Printer, payload *loadbalancer.CreateLoadBalancerPayload) error { + payloadBytes, err := json.MarshalIndent(*payload, "", " ") + if err != nil { + return fmt.Errorf("marshal create load balancer payload: %w", err) + } + p.Outputln(string(payloadBytes)) + + return nil +} + +func outputUpdateResult(p *print.Printer, payload *loadbalancer.UpdateLoadBalancerPayload) error { + payloadBytes, err := json.MarshalIndent(*payload, "", " ") + if err != nil { + return fmt.Errorf("marshal update load balancer payload: %w", err) + } + p.Outputln(string(payloadBytes)) + + return nil +} + +func modifyListener(resp *loadbalancer.LoadBalancer) *[]loadbalancer.Listener { + listeners := *resp.Listeners + + for i := range listeners { + listeners[i].Name = nil + } + + return &listeners +} diff --git a/internal/cmd/load-balancer/generate-payload/generate_payload_test.go b/internal/cmd/load-balancer/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..a1661d545 --- /dev/null +++ b/internal/cmd/load-balancer/generate-payload/generate_payload_test.go @@ -0,0 +1,295 @@ +package generatepayload + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "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/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + loadBalancerNameFlag: "example-name", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + LoadBalancerName: utils.Ptr("example-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest)) loadbalancer.ApiGetLoadBalancerRequest { + request := testClient.GetLoadBalancer(testCtx, testProjectId, "example-name") + 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: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + }, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, loadBalancerNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LoadBalancerName = nil + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, 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 loadbalancer.ApiGetLoadBalancerRequest + isValid bool + }{ + { + 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) + } + }) + } +} + +func TestModifyListeners(t *testing.T) { + tests := []struct { + description string + response *loadbalancer.LoadBalancer + expected *[]loadbalancer.Listener + }{ + { + description: "base", + response: &loadbalancer.LoadBalancer{ + Listeners: &[]loadbalancer.Listener{ + { + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + Name: utils.Ptr(""), + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + }, + { + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + Name: utils.Ptr(""), + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + }, + }, + }, + expected: &[]loadbalancer.Listener{ + { + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + Name: nil, + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + }, + { + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + Name: nil, + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := modifyListener(tt.response) + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Errorf("expected output to be %+v, got %+v", tt.expected, output) + } + }) + } +} diff --git a/internal/cmd/load-balancer/list/list.go b/internal/cmd/load-balancer/list/list.go new file mode 100644 index 000000000..d5b568216 --- /dev/null +++ b/internal/cmd/load-balancer/list/list.go @@ -0,0 +1,160 @@ +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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Load Balancers", + Long: "Lists all Load Balancers.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all load balancers`, + "$ stackit load-balancer list"), + examples.NewExample( + `List all loadbalancers in JSON format`, + "$ stackit load-balancer list --output-format json"), + examples.NewExample( + `List up to 10 load balancers `, + "$ stackit load-balancer list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get load balancers: %w", err) + } + + if resp.LoadBalancers == nil || (resp.LoadBalancers != nil && len(*resp.LoadBalancers) == 0) { + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + p.Info("No load balancers found for project %q\n", projectLabel) + return nil + } + + loadBalancers := *resp.LoadBalancers + // Truncate output + if model.Limit != nil && len(loadBalancers) > int(*model.Limit) { + loadBalancers = loadBalancers[:*model.Limit] + } + + return outputResult(p, model.OutputFormat, loadBalancers) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiListLoadBalancersRequest { + req := apiClient.ListLoadBalancers(ctx, model.ProjectId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, loadBalancers []loadbalancer.LoadBalancer) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(loadBalancers, "", " ") + if err != nil { + return fmt.Errorf("marshal load balancer list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("NAME", "STATE", "IP ADDRESS", "LISTENERS", "TARGET POOLS") + for i := range loadBalancers { + l := loadBalancers[i] + externalAdress := "-" + if l.ExternalAddress != nil { + externalAdress = *l.ExternalAddress + } + table.AddRow(*l.Name, *l.Status, externalAdress, len(*l.Listeners), len(*l.TargetPools)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/load-balancer/list/list_test.go b/internal/cmd/load-balancer/list/list_test.go new file mode 100644 index 000000000..0f035066d --- /dev/null +++ b/internal/cmd/load-balancer/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/print" + "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/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.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, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiListLoadBalancersRequest)) loadbalancer.ApiListLoadBalancersRequest { + request := testClient.ListLoadBalancers(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) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.ApiListLoadBalancersRequest + }{ + { + 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/load-balancer/load_balancer.go b/internal/cmd/load-balancer/load_balancer.go new file mode 100644 index 000000000..c6f6be2f5 --- /dev/null +++ b/internal/cmd/load-balancer/load_balancer.go @@ -0,0 +1,44 @@ +package loadbalancer + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/list" + observabilitycredentials "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/quota" + targetpool "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/update" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "load-balancer", + Aliases: []string{"lb"}, + Short: "Provides functionality for Load Balancer", + Long: "Provides functionality for Load Balancer.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(generatepayload.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(quota.NewCmd(p)) + cmd.AddCommand(observabilitycredentials.NewCmd(p)) + cmd.AddCommand(targetpool.NewCmd(p)) + cmd.AddCommand(update.NewCmd(p)) +} diff --git a/internal/cmd/load-balancer/observability-credentials/add/add.go b/internal/cmd/load-balancer/observability-credentials/add/add.go new file mode 100644 index 000000000..e97963d9f --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/add/add.go @@ -0,0 +1,164 @@ +package add + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + displayNameFlag = "display-name" + usernameFlag = "username" + passwordFlag = "password" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName *string + Username *string + Password *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "add", + Short: "Adds observability credentials to Load Balancer", + Long: "Adds existing observability credentials (username and password) to Load Balancer. The credentials can be for Argus or another monitoring tool.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Add observability credentials to a load balancer with username "xxx" and display name "yyy". The password is entered using the terminal`, + "$ stackit load-balancer observability-credentials add --username xxx --display-name yyy"), + examples.NewExample( + `Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag`, + "$ stackit load-balancer observability-credentials add --username xxx --password @./password.txt --display-name yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Prompt for password if not passed in as a flag + if model.Password == nil { + pwd, err := p.PromptForPassword("Enter user password: ") + if err != nil { + return fmt.Errorf("prompt for password: %w", err) + } + model.Password = utils.Ptr(pwd) + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to add observability credentials for Load Balancer on project %q?", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("add Load Balancer observability credentials: %w", err) + } + + return outputResult(p, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Credentials display name") + cmd.Flags().String(usernameFlag, "", "Username") + cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`) + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, usernameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Username: flags.FlagToStringPointer(p, cmd, usernameFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiCreateCredentialsRequest { + req := apiClient.CreateCredentials(ctx, model.ProjectId) + req = req.XRequestID(uuid.NewString()) + + req = req.CreateCredentialsPayload(loadbalancer.CreateCredentialsPayload{ + DisplayName: model.DisplayName, + Username: model.Username, + Password: model.Password, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *loadbalancer.CreateCredentialsResponse) error { + if resp.Credential == nil { + return fmt.Errorf("nil observability credentials response") + } + + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal Load Balancer observability credentials: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Added Load Balancer observability credentials on project %q. Credentials reference: %q\n", projectLabel, *resp.Credential.CredentialsRef) + return nil + } +} diff --git a/internal/cmd/load-balancer/observability-credentials/add/add_test.go b/internal/cmd/load-balancer/observability-credentials/add/add_test.go new file mode 100644 index 000000000..85fc6b7d6 --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/add/add_test.go @@ -0,0 +1,195 @@ +package add + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + displayNameFlag: "name", + usernameFlag: "username", + passwordFlag: "pwd", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr("name"), + Username: utils.Ptr("username"), + Password: utils.Ptr("pwd"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiCreateCredentialsRequest)) loadbalancer.ApiCreateCredentialsRequest { + request := testClient.CreateCredentials(testCtx, testProjectId) + request = request.CreateCredentialsPayload(loadbalancer.CreateCredentialsPayload{ + DisplayName: utils.Ptr("name"), + Username: utils.Ptr("username"), + Password: utils.Ptr("pwd"), + }) + 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: "display name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "username name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, usernameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.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), + cmpopts.IgnoreFields(loadbalancer.ApiCreateCredentialsRequest{}, "xRequestID"), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/load-balancer/observability-credentials/delete/delete.go b/internal/cmd/load-balancer/observability-credentials/delete/delete.go new file mode 100644 index 000000000..a3c6eb87d --- /dev/null +++ b/internal/cmd/load-balancer/observability-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/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + loadbalancerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + credentialsRefArg = "CREDENTIALS_REF" //nolint:gosec // linter false positive +) + +type inputModel struct { + *globalflags.GlobalFlagModel + CredentialsRef string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", credentialsRefArg), + Short: "Deletes observability credentials for Load Balancer", + Long: "Deletes observability credentials for Load Balancer.", + Args: args.SingleArg(credentialsRefArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete observability credentials with reference "credentials-xxx" for Load Balancer`, + "$ stackit load-balancer observability-credentials delete credentials-xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + credentialsLabel, err := loadbalancerUtils.GetCredentialsDisplayName(ctx, apiClient, model.ProjectId, model.CredentialsRef) + if err != nil { + p.Debug(print.ErrorLevel, "get observability credentials display name: %v", err) + credentialsLabel = model.CredentialsRef + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete observability credentials %q on project %q?(This cannot be undone)", credentialsLabel, projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete Load Balancer observability credentials: %w", err) + } + + p.Info("Deleted observability credentials %q on project %q\n", credentialsLabel, projectLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsRef := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + CredentialsRef: credentialsRef, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiDeleteCredentialsRequest { + req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.CredentialsRef) + return req +} diff --git a/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go b/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go new file mode 100644 index 000000000..629b93f5c --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go @@ -0,0 +1,213 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() + +const testCredentialsRef = "credentials-xxx" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsRef, + } + 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, + Verbosity: globalflags.VerbosityDefault, + }, + CredentialsRef: testCredentialsRef, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiDeleteCredentialsRequest)) loadbalancer.ApiDeleteCredentialsRequest { + request := testClient.DeleteCredentials(testCtx, testProjectId, testCredentialsRef) + 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: "credentials ref invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.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/load-balancer/observability-credentials/describe/describe.go b/internal/cmd/load-balancer/observability-credentials/describe/describe.go new file mode 100644 index 000000000..31d109501 --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/describe/describe.go @@ -0,0 +1,121 @@ +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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + credentialsRefArg = "CREDENTIALS_REF" //nolint:gosec // linter false positive +) + +type inputModel struct { + *globalflags.GlobalFlagModel + CredentialsRef string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", credentialsRefArg), + Short: "Shows details of observability credentials for Load Balancer", + Long: "Shows details of observability credentials for Load Balancer.", + Args: args.SingleArg(credentialsRefArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of observability credentials with reference "credentials-xxx"`, + "$ stackit load-balancer observability-credentials describe credentials-xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe Load Balancer observability credentials: %w", err) + } + + return outputResult(p, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsRef := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + CredentialsRef: credentialsRef, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetCredentialsRequest { + req := apiClient.GetCredentials(ctx, model.ProjectId, model.CredentialsRef) + return req +} + +func outputResult(p *print.Printer, outputFormat string, credentials *loadbalancer.GetCredentialsResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal Load Balancer observability credentials: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("REFERENCE", *credentials.Credential.CredentialsRef) + table.AddSeparator() + table.AddRow("DISPLAY NAME", *credentials.Credential.DisplayName) + table.AddSeparator() + table.AddRow("USERNAME", *credentials.Credential.Username) + table.AddSeparator() + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go b/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go new file mode 100644 index 000000000..d916923fd --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go @@ -0,0 +1,213 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() + +const testCredentialsRef = "credentials-test" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsRef, + } + 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, + Verbosity: globalflags.VerbosityDefault, + }, + CredentialsRef: testCredentialsRef, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiGetCredentialsRequest)) loadbalancer.ApiGetCredentialsRequest { + request := testClient.GetCredentials(testCtx, testProjectId, testCredentialsRef) + 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: "credentials ref invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.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/load-balancer/observability-credentials/list/list.go b/internal/cmd/load-balancer/observability-credentials/list/list.go new file mode 100644 index 000000000..21cb4dcad --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/list/list.go @@ -0,0 +1,157 @@ +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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + instanceIdFlag = "instance-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all observability credentials for Load Balancer", + Long: "Lists all observability credentials for Load Balancer.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all observability credentials for Load Balancer`, + "$ stackit load-balancer observability-credentials list"), + examples.NewExample( + `List all observability credentials for Load Balancer in JSON format`, + "$ stackit load-balancer observability-credentials list --output-format json"), + examples.NewExample( + `List up to 10 observability credentials for Load Balancer`, + "$ stackit load-balancer observability-credentials list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Load Balancer observability credentials: %w", err) + } + credentialsPtr := resp.Credentials + if credentialsPtr == nil || (credentialsPtr != nil && len(*credentialsPtr) == 0) { + p.Info("No observability credentials found for Load Balancer on project %q\n", projectLabel) + return nil + } + + credentials := *credentialsPtr + + // Truncate output + if model.Limit != nil && len(credentials) > int(*model.Limit) { + credentials = credentials[:*model.Limit] + } + return outputResult(p, model.OutputFormat, credentials) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, credentials []loadbalancer.CredentialsResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(credentials, "", " ") + if err != nil { + return fmt.Errorf("marshal Load Balancer observability credentials list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("REFERENCE", "DISPLAY NAME", "USERNAME") + for i := range credentials { + c := credentials[i] + table.AddRow(*c.CredentialsRef, *c.DisplayName, *c.Username) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/load-balancer/observability-credentials/list/list_test.go b/internal/cmd/load-balancer/observability-credentials/list/list_test.go new file mode 100644 index 000000000..9c564117e --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/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/print" + "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/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.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, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiListCredentialsRequest)) loadbalancer.ApiListCredentialsRequest { + request := testClient.ListCredentials(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) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.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/load-balancer/observability-credentials/observability-credentials.go b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go new file mode 100644 index 000000000..1c7dcec9c --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go @@ -0,0 +1,35 @@ +package credentials + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/add" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "observability-credentials", + Short: "Provides functionality for Load Balancer observability credentials", + Long: `Provides functionality for Load Balancer observability credentials. These commands can be used to store and update existing credentials, which are valid to be used for Load Balancer observability. This means, e.g. when using Argus, first of all these credentials must be created for that Argus instance (by using "stackit argus credentials create") and then can be managed for a Load Balancer by using the commands in this group.`, + Args: args.NoArgs, + Aliases: []string{"credentials"}, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(add.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(update.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) +} diff --git a/internal/cmd/load-balancer/observability-credentials/update/update.go b/internal/cmd/load-balancer/observability-credentials/update/update.go new file mode 100644 index 000000000..0f395139f --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/update/update.go @@ -0,0 +1,167 @@ +package update + +import ( + "context" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + loadBalancerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + displayNameFlag = "display-name" + usernameFlag = "username" + passwordFlag = "password" + + credentialsRefArg = "CREDENTIALS_REF" //nolint:gosec // linter false positive +) + +type inputModel struct { + *globalflags.GlobalFlagModel + CredentialsRef string + DisplayName *string + Username *string + Password *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates observability credentials for Load Balancer", + Long: "Updates existing observability credentials (username and password) for Load Balancer. The credentials can be for Argus or another monitoring tool.", + Args: args.SingleArg(credentialsRefArg, nil), + Example: examples.Build( + examples.NewExample( + `Update the password and username of observability credentials of Load Balancer with credentials reference "credentials-xxx". The password is entered using the terminal`, + "$ stackit load-balancer observability-credentials update credentials-xxx --username new-username"), + examples.NewExample( + `Update the password of observability credentials of Load Balancer with credentials reference "credentials-xxx", by providing the path to a file with the new password as flag`, + "$ stackit load-balancer observability-credentials update credentials-xxx --password @./new-password.txt"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + credentialsLabel, err := loadBalancerUtils.GetCredentialsDisplayName(ctx, apiClient, model.ProjectId, model.CredentialsRef) + if err != nil { + p.Debug(print.ErrorLevel, "get credentials display name: %v", err) + credentialsLabel = model.CredentialsRef + } + + // Prompt for password if not passed in as a flag + if model.Password == nil { + pwd, err := p.PromptForPassword("Enter new password: ") + if err != nil { + return fmt.Errorf("prompt for password: %w", err) + } + model.Password = utils.Ptr(pwd) + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update observability credentials %q for Load Balancer on project %q?", credentialsLabel, projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update Load Balancer observability credentials: %w", err) + } + + p.Info("Updated observability credentials %q for Load Balancer on project %q\n", credentialsLabel, projectLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Credentials name") + cmd.Flags().String(usernameFlag, "", "Username") + cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsRef := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + displayName := flags.FlagToStringPointer(p, cmd, displayNameFlag) + username := flags.FlagToStringPointer(p, cmd, usernameFlag) + password := flags.FlagToStringPointer(p, cmd, passwordFlag) + + return &inputModel{ + GlobalFlagModel: globalFlags, + CredentialsRef: credentialsRef, + DisplayName: displayName, + Username: username, + Password: password, + }, nil +} + +type loadBalancerClient interface { + UpdateCredentials(ctx context.Context, instanceId, projectId string) loadbalancer.ApiUpdateCredentialsRequest + GetCredentialsExecute(ctx context.Context, instanceId, projectId string) (*loadbalancer.GetCredentialsResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient loadBalancerClient) (loadbalancer.ApiUpdateCredentialsRequest, error) { + req := apiClient.UpdateCredentials(ctx, model.ProjectId, model.CredentialsRef) + + currentCredentials, err := apiClient.GetCredentialsExecute(ctx, model.ProjectId, model.CredentialsRef) + if err != nil { + return req, fmt.Errorf("get Load Balancer observability credentials: %w", err) + } + + payload := loadbalancer.UpdateCredentialsPayload{ + DisplayName: currentCredentials.Credential.DisplayName, + Username: currentCredentials.Credential.Username, + Password: model.Password, + } + + if model.DisplayName != nil { + payload.DisplayName = model.DisplayName + } + if model.Username != nil { + payload.Username = model.Username + } + req = req.UpdateCredentialsPayload(payload) + return req, nil +} diff --git a/internal/cmd/load-balancer/observability-credentials/update/update_test.go b/internal/cmd/load-balancer/observability-credentials/update/update_test.go new file mode 100644 index 000000000..6b30da82b --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/update/update_test.go @@ -0,0 +1,304 @@ +package update + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "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/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} + +type loadBalancerClientMocked struct { + getCredentialsError bool + getCredentialsResponse *loadbalancer.GetCredentialsResponse +} + +func (c *loadBalancerClientMocked) UpdateCredentials(ctx context.Context, projectId, credentialsRef string) loadbalancer.ApiUpdateCredentialsRequest { + return testClient.UpdateCredentials(ctx, projectId, credentialsRef) +} + +func (c *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { + if c.getCredentialsError { + return nil, fmt.Errorf("get credentials failed") + } + return c.getCredentialsResponse, nil +} + +var testProjectId = uuid.NewString() + +const testCredentialsRef = "credentials-test" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialsRef, + } + 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, + displayNameFlag: "name", + usernameFlag: "username", + passwordFlag: "pwd", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr("name"), + Username: utils.Ptr("username"), + Password: utils.Ptr("pwd"), + CredentialsRef: testCredentialsRef, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateCredentialsRequest)) loadbalancer.ApiUpdateCredentialsRequest { + request := testClient.UpdateCredentials(testCtx, testProjectId, testCredentialsRef) + request = request.UpdateCredentialsPayload(loadbalancer.UpdateCredentialsPayload{ + DisplayName: utils.Ptr("name"), + Username: utils.Ptr("username"), + Password: utils.Ptr("pwd"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureGetCredentialsResponse(mods ...func(response *loadbalancer.GetCredentialsResponse)) *loadbalancer.GetCredentialsResponse { + response := &loadbalancer.GetCredentialsResponse{ + Credential: &loadbalancer.CredentialsResponse{ + DisplayName: utils.Ptr("name"), + Username: utils.Ptr("username"), + }, + } + for _, mod := range mods { + mod(response) + } + return response +} + +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: "credentials ref invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.ApiUpdateCredentialsRequest + getCredentialsFails bool + getCredentialsResponse *loadbalancer.GetCredentialsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getCredentialsResponse: fixtureGetCredentialsResponse(), + isValid: true, + }, + { + description: "no display name", + model: fixtureInputModel( + func(model *inputModel) { + model.DisplayName = nil + }, + ), + expectedRequest: fixtureRequest(), + getCredentialsResponse: fixtureGetCredentialsResponse(), + isValid: true, + }, + { + description: "no username name", + model: fixtureInputModel( + func(model *inputModel) { + model.Username = nil + }, + ), + expectedRequest: fixtureRequest(), + getCredentialsResponse: fixtureGetCredentialsResponse(), + isValid: true, + }, + { + description: "get credentials fails", + model: fixtureInputModel(), + getCredentialsFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getCredentialsError: tt.getCredentialsFails, + getCredentialsResponse: tt.getCredentialsResponse, + } + request, err := buildRequest(testCtx, tt.model, client) + + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + if !tt.isValid { + t.Fatal("expected error but none thrown") + } + + 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/load-balancer/quota/quota.go b/internal/cmd/load-balancer/quota/quota.go new file mode 100644 index 000000000..7762e78bc --- /dev/null +++ b/internal/cmd/load-balancer/quota/quota.go @@ -0,0 +1,109 @@ +package quota + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "quota", + Short: "Shows the configured Load Balancer quota", + Long: "Shows the configured Load Balancer quota for the project. If you want to change the quota, please create a support ticket in the STACKIT Help Center (https://support.stackit.cloud)", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Get the configured load balancer quota for the project`, + "$ stackit load-balancer quota"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get load balancer quota: %w", err) + } + + return outputResult(p, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetQuotaRequest { + req := apiClient.GetQuota(ctx, model.ProjectId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, quota *loadbalancer.GetQuotaResponse) error { + switch outputFormat { + case print.PrettyOutputFormat: + + maxLoadBalancers := "Unlimited" + if quota.MaxLoadBalancers != nil && *quota.MaxLoadBalancers != -1 { + maxLoadBalancers = strconv.FormatInt(*quota.MaxLoadBalancers, 10) + } + + p.Outputf("Maximum number of load balancers allowed: %s\n", maxLoadBalancers) + + return nil + default: + details, err := json.MarshalIndent(quota, "", " ") + if err != nil { + return fmt.Errorf("marshal quota: %w", err) + } + + p.Outputln(string(details)) + + return nil + } +} diff --git a/internal/cmd/load-balancer/quota/quota_test.go b/internal/cmd/load-balancer/quota/quota_test.go new file mode 100644 index 000000000..47bc747c9 --- /dev/null +++ b/internal/cmd/load-balancer/quota/quota_test.go @@ -0,0 +1,165 @@ +package quota + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() + +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, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiGetQuotaRequest)) loadbalancer.ApiGetQuotaRequest { + request := testClient.GetQuota(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: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 + isValid bool + expectedRequest loadbalancer.ApiGetQuotaRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + 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/load-balancer/target-pool/add-target/add_target.go b/internal/cmd/load-balancer/target-pool/add-target/add_target.go new file mode 100644 index 000000000..13d816e9f --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/add-target/add_target.go @@ -0,0 +1,149 @@ +package addtarget + +import ( + "context" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/spf13/cobra" +) + +const ( + ipArg = "TARGET_IP" + + lbNameFlag = "lb-name" + targetNameFlag = "target-name" + targetPoolNameFlag = "target-pool-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + TargetPoolName string + LBName string + TargetName string + IP string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("add-target %s", ipArg), + Short: "Adds a target to a target pool", + Long: fmt.Sprintf("%s\n%s", + "Adds a target to a target pool.", + "The target IP must by unique within a target pool and must be a valid IPv4 or IPv6."), + Args: args.SingleArg(ipArg, nil), + Example: examples.Build( + examples.NewExample( + `Add a target with IP 1.2.3.4 and name "my-new-target" to target pool "my-target-pool" of load balancer with name "my-load-balancer"`, + "$ stackit load-balancer target-pool add-target 1.2.3.4 --target-name my-new-target --target-pool-name my-target-pool --lb-name my-load-balancer"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to add a target with IP %q to target pool %q of load balancer %q?", model.IP, model.TargetPoolName, model.LBName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("add target to target pool: %w", err) + } + + p.Info("Added target to target pool of load balancer %q\n", model.LBName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(targetNameFlag, "n", "", "Target name") + cmd.Flags().String(targetPoolNameFlag, "", "Target pool name") + cmd.Flags().String(lbNameFlag, "", "Load balancer name") + + err := flags.MarkFlagsRequired(cmd, lbNameFlag, targetNameFlag, targetPoolNameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + ip := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + TargetPoolName: cmd.Flag(targetPoolNameFlag).Value.String(), + LBName: cmd.Flag(lbNameFlag).Value.String(), + TargetName: cmd.Flag(targetNameFlag).Value.String(), + IP: ip, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient utils.LoadBalancerClient) (loadbalancer.ApiUpdateTargetPoolRequest, error) { + req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.LBName, model.TargetPoolName) + + targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.LBName, model.TargetPoolName) + if err != nil { + return req, fmt.Errorf("get load balancer target pool: %w", err) + } + + newTarget := &loadbalancer.Target{ + DisplayName: &model.TargetName, + Ip: &model.IP, + } + err = utils.AddTargetToTargetPool(targetPool, newTarget) + if err != nil { + return req, fmt.Errorf("add target to target pool: %w", err) + } + + payload := utils.ToPayloadTargetPool(targetPool) + if payload == nil { + return req, fmt.Errorf("nil payload") + } + + return req.UpdateTargetPoolPayload(*payload), nil +} diff --git a/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go b/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go new file mode 100644 index 000000000..128e2c60a --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go @@ -0,0 +1,424 @@ +package addtarget + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &loadbalancer.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testLBName = "my-load-balancer" + testTargetPoolName = "target-pool-1" + testTargetName = "my-target" + testIP = "1.1.1.1" +) + +type loadBalancerClientMocked struct { + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer +} + +func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { + if m.getCredentialsFails { + return nil, fmt.Errorf("could not get credentials") + } + return m.getCredentialsResp, nil +} + +func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) { + if m.getLoadBalancerFails { + return nil, fmt.Errorf("could not get load balancer") + } + return m.getLoadBalancerResp, nil +} + +func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest { + return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName) +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testIP, + } + 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, + lbNameFlag: testLBName, + targetNameFlag: testTargetName, + targetPoolNameFlag: testTargetPoolName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + TargetPoolName: testTargetPoolName, + LBName: testLBName, + TargetName: testTargetName, + IP: testIP, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureTargets() *[]loadbalancer.Target { + return &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + } +} + +func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer { + lb := loadbalancer.LoadBalancer{ + Name: utils.Ptr(testLBName), + TargetPools: &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr(testTargetPoolName), + Targets: fixtureTargets(), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + }, + { + Name: utils.Ptr("target-pool-2"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("6.7.8.9"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("9.8.7.6"), + }, + }, + }, + }, + } + + for _, mod := range mods { + mod(&lb) + } + return &lb +} + +func fixturePayload(mods ...func(payload *loadbalancer.UpdateTargetPoolPayload)) *loadbalancer.UpdateTargetPoolPayload { + payload := &loadbalancer.UpdateTargetPoolPayload{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: fixtureTargets(), + } + + for _, mod := range mods { + mod(payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateTargetPoolRequest)) loadbalancer.ApiUpdateTargetPoolRequest { + request := testClient.UpdateTargetPool(testCtx, testProjectId, testLBName, testTargetPoolName) + request = request.UpdateTargetPoolPayload(*fixturePayload()) + 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 arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + 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: "ip missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "load balancer name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, lbNameFlag) + }), + isValid: false, + }, + { + description: "target name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetNameFlag) + }), + isValid: false, + }, + { + description: "target pool name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetPoolNameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + + 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(p, 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 + isValid bool + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + expectedRequest loadbalancer.ApiUpdateTargetPoolRequest + }{ + { + description: "base", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = &[]loadbalancer.Target{ + (*fixtureTargets())[0], + (*fixtureTargets())[1], + { + DisplayName: utils.Ptr(testTargetName), + Ip: utils.Ptr(testIP), + }, + } + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "empty targets", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + (*lb.TargetPools)[0].Targets = &[]loadbalancer.Target{} + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(testTargetName), + Ip: utils.Ptr(testIP), + }, + } + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "nil targets", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + (*lb.TargetPools)[0].Targets = nil + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(testTargetName), + Ip: utils.Ptr(testIP), + }, + } + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "get load balancer fails", + model: fixtureInputModel(), + getLoadBalancerFails: true, + isValid: false, + }, + { + description: "target pool not found", + model: fixtureInputModel( + func(model *inputModel) { + model.TargetPoolName = "not-existent" + }), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "nil target pool", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + *lb.TargetPools = nil + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getLoadBalancerFails: tt.getLoadBalancerFails, + getLoadBalancerResp: tt.getLoadBalancerResp, + } + 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/load-balancer/target-pool/describe/describe.go b/internal/cmd/load-balancer/target-pool/describe/describe.go new file mode 100644 index 000000000..c1f06d196 --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/describe/describe.go @@ -0,0 +1,203 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + targetPoolNameArg = "TARGET_POOL_NAME" + + lbNameFlag = "lb-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + TargetPoolName string + LBName string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", targetPoolNameArg), + Short: "Shows details of a target pool in a Load Balancer", + Long: "Shows details of a target pool in a Load Balancer.", + Args: args.SingleArg(targetPoolNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of a target pool with name "pool" in load balancer with name "my-load-balancer"`, + "$ stackit load-balancer target-pool describe pool --lb-name my-load-balancer"), + examples.NewExample( + `Get details of a target pool with name "pool" in load balancer with name "my-load-balancer in JSON output"`, + "$ stackit load-balancer target-pool describe pool --lb-name my-load-balancer --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read load balancer: %w", err) + } + + targetPool := utils.FindLoadBalancerTargetPoolByName(*resp.TargetPools, model.TargetPoolName) + if targetPool == nil { + return fmt.Errorf("target pool not found") + } + + listener := utils.FindLoadBalancerListenerByTargetPool(*resp.Listeners, *targetPool.Name) + + return outputResult(p, model.OutputFormat, *targetPool, listener) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(lbNameFlag, "", "Name of the load balancer") + + err := flags.MarkFlagsRequired(cmd, lbNameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + targetPoolName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + TargetPoolName: targetPoolName, + LBName: cmd.Flag(lbNameFlag).Value.String(), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest { + req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.LBName) + return req +} + +func outputResult(p *print.Printer, outputFormat string, targetPool loadbalancer.TargetPool, listener *loadbalancer.Listener) error { + switch outputFormat { + case print.JSONOutputFormat: + output := struct { + *loadbalancer.TargetPool + Listener *loadbalancer.Listener `json:"attached_listener"` + }{ + &targetPool, + listener, + } + details, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("marshal load balancer: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + return outputResultAsTable(p, targetPool, listener) + } +} + +func outputResultAsTable(p *print.Printer, targetPool loadbalancer.TargetPool, listener *loadbalancer.Listener) error { + sessionPersistence := "None" + if targetPool.SessionPersistence != nil && targetPool.SessionPersistence.UseSourceIpAddress != nil && *targetPool.SessionPersistence.UseSourceIpAddress { + sessionPersistence = "Use Source IP" + } + + healthCheckInterval := "-" + healthCheckUnhealthyThreshold := "-" + healthCheckHealthyThreshold := "-" + if targetPool.ActiveHealthCheck != nil { + if targetPool.ActiveHealthCheck.Interval != nil { + healthCheckInterval = *targetPool.ActiveHealthCheck.Interval + } + if targetPool.ActiveHealthCheck.UnhealthyThreshold != nil { + healthCheckUnhealthyThreshold = strconv.FormatInt(*targetPool.ActiveHealthCheck.UnhealthyThreshold, 10) + } + if targetPool.ActiveHealthCheck.HealthyThreshold != nil { + healthCheckHealthyThreshold = strconv.FormatInt(*targetPool.ActiveHealthCheck.HealthyThreshold, 10) + } + } + + targets := "-" + if targetPool.Targets != nil { + var targetsSlice []string + for _, target := range *targetPool.Targets { + targetStr := fmt.Sprintf("%s (%s)", *target.DisplayName, *target.Ip) + targetsSlice = append(targetsSlice, targetStr) + } + targets = strings.Join(targetsSlice, "\n") + } + + listenerStr := "-" + if listener != nil { + listenerStr = fmt.Sprintf("%s (Port:%d, Protocol: %s)", *listener.Name, *listener.Port, *listener.Protocol) + } + + table := tables.NewTable() + table.AddRow("NAME", *targetPool.Name) + table.AddSeparator() + table.AddRow("TARGET PORT", *targetPool.TargetPort) + table.AddSeparator() + table.AddRow("ATTACHED LISTENER", listenerStr) + table.AddSeparator() + table.AddRow("TARGETS", targets) + table.AddSeparator() + table.AddRow("SESSION PERSISTENCE", sessionPersistence) + table.AddSeparator() + table.AddRow("HEALTH CHECK INTERVAL", healthCheckInterval) + table.AddSeparator() + table.AddRow("HEALTH CHECK DOWN AFTER", healthCheckUnhealthyThreshold) + table.AddSeparator() + table.AddRow("HEALTH CHECK UP AFTER", healthCheckHealthyThreshold) + table.AddSeparator() + + err := p.PagerDisplay(table.Render()) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + + return nil +} diff --git a/internal/cmd/load-balancer/target-pool/describe/describe_test.go b/internal/cmd/load-balancer/target-pool/describe/describe_test.go new file mode 100644 index 000000000..9127dc29c --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/describe/describe_test.go @@ -0,0 +1,219 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &loadbalancer.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testLoadBalancerName = "my-load-balancer" + testTargetPoolName = "target-pool-1" +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testTargetPoolName, + } + 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, + lbNameFlag: testLoadBalancerName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + LBName: testLoadBalancerName, + TargetPoolName: testTargetPoolName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest)) loadbalancer.ApiGetLoadBalancerRequest { + request := testClient.GetLoadBalancer(testCtx, testProjectId, testLoadBalancerName) + 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 arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + 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: "target pool name empty", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "load balancer name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, lbNameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + + 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(p, 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 + isValid bool + expectedRequest loadbalancer.ApiGetLoadBalancerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + 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/load-balancer/target-pool/remove-target/remove_target.go b/internal/cmd/load-balancer/target-pool/remove-target/remove_target.go new file mode 100644 index 000000000..36a84313f --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/remove-target/remove_target.go @@ -0,0 +1,145 @@ +package removetarget + +import ( + "context" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/spf13/cobra" +) + +const ( + ipArg = "TARGET_IP" + + lbNameFlag = "lb-name" + targetPoolNameFlag = "target-pool-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + TargetPoolName string + LBName string + IP string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("remove-target %s", ipArg), + Short: "Removes a target from a target pool", + Long: "Removes a target from a target pool.", + Args: args.SingleArg(ipArg, nil), + Example: examples.Build( + examples.NewExample( + `Remove target with IP 1.2.3.4 from target pool "my-target-pool" of load balancer with name "my-load-balancer"`, + "$ stackit load-balancer target-pool remove-target 1.2.3.4 --target-pool-name my-target-pool --lb-name my-load-balancer"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + targetLabel, err := utils.GetTargetName(ctx, apiClient, model.ProjectId, model.LBName, model.TargetPoolName, model.IP) + if err != nil { + p.Debug(print.ErrorLevel, "get target name: %v", err) + targetLabel = model.IP + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to remove target %q from target pool %q of load balancer %q?", targetLabel, model.TargetPoolName, model.LBName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("remove target from target pool: %w", err) + } + + p.Info("Removed target from target pool of load balancer %q\n", model.LBName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(lbNameFlag, "", "Load balancer name") + cmd.Flags().String(targetPoolNameFlag, "", "Target IP of the target to remove. Must be a valid IPv4 or IPv6") + + err := flags.MarkFlagsRequired(cmd, lbNameFlag, targetPoolNameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + ip := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + TargetPoolName: cmd.Flag(targetPoolNameFlag).Value.String(), + LBName: cmd.Flag(lbNameFlag).Value.String(), + IP: ip, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient utils.LoadBalancerClient) (loadbalancer.ApiUpdateTargetPoolRequest, error) { + req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.LBName, model.TargetPoolName) + + targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.LBName, model.TargetPoolName) + if err != nil { + return req, fmt.Errorf("get load balancer target pool: %w", err) + } + + err = utils.RemoveTargetFromTargetPool(targetPool, model.IP) + if err != nil { + return req, fmt.Errorf("remove target to target pool: %w", err) + } + + payload := utils.ToPayloadTargetPool(targetPool) + if payload == nil { + return req, fmt.Errorf("nil payload") + } + + return req.UpdateTargetPoolPayload(*payload), nil +} diff --git a/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go b/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go new file mode 100644 index 000000000..bec625abd --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go @@ -0,0 +1,394 @@ +package removetarget + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &loadbalancer.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testLBName = "my-load-balancer" + testTargetPoolName = "target-pool-1" + testTargetName = "my-target" + testIP = "1.2.3.4" +) + +type loadBalancerClientMocked struct { + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer +} + +func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { + if m.getCredentialsFails { + return nil, fmt.Errorf("could not get credentials") + } + return m.getCredentialsResp, nil +} + +func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) { + if m.getLoadBalancerFails { + return nil, fmt.Errorf("could not get load balancer") + } + return m.getLoadBalancerResp, nil +} + +func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest { + return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName) +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testIP, + } + 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, + lbNameFlag: testLBName, + targetPoolNameFlag: testTargetPoolName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + LBName: testLBName, + TargetPoolName: testTargetPoolName, + IP: testIP, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureTargets() *[]loadbalancer.Target { + return &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + } +} + +func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer { + lb := loadbalancer.LoadBalancer{ + Name: utils.Ptr(testLBName), + TargetPools: &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr(testTargetPoolName), + Targets: fixtureTargets(), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + }, + { + Name: utils.Ptr("target-pool-2"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("6.7.8.9"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("9.8.7.6"), + }, + }, + }, + }, + } + + for _, mod := range mods { + mod(&lb) + } + return &lb +} + +func fixturePayload(mods ...func(payload *loadbalancer.UpdateTargetPoolPayload)) *loadbalancer.UpdateTargetPoolPayload { + payload := &loadbalancer.UpdateTargetPoolPayload{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: fixtureTargets(), + } + + for _, mod := range mods { + mod(payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateTargetPoolRequest)) loadbalancer.ApiUpdateTargetPoolRequest { + request := testClient.UpdateTargetPool(testCtx, testProjectId, testLBName, testTargetPoolName) + request = request.UpdateTargetPoolPayload(*fixturePayload()) + 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 arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + 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: "ip missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "load balancer name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, lbNameFlag) + }), + isValid: false, + }, + { + description: "target pool name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetPoolNameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + + 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(p, 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 + isValid bool + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + expectedRequest loadbalancer.ApiUpdateTargetPoolRequest + }{ + { + description: "base", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = utils.Ptr((*payload.Targets)[1:]) + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "empty targets", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + (*lb.TargetPools)[0].Targets = &[]loadbalancer.Target{} + }), + isValid: false, + }, + { + description: "target not found", + model: fixtureInputModel( + func(model *inputModel) { + model.IP = "9.9.9.9" + }), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "nil targets", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + (*lb.TargetPools)[0].Targets = nil + }), + isValid: false, + }, + { + description: "get load balancer fails", + model: fixtureInputModel(), + getLoadBalancerFails: true, + isValid: false, + }, + { + description: "target pool not found", + model: fixtureInputModel( + func(model *inputModel) { + model.TargetPoolName = "not-existent" + }), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "nil target pool", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + *lb.TargetPools = nil + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getLoadBalancerFails: tt.getLoadBalancerFails, + getLoadBalancerResp: tt.getLoadBalancerResp, + } + 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/load-balancer/target-pool/target_pool.go b/internal/cmd/load-balancer/target-pool/target_pool.go new file mode 100644 index 000000000..78a8d50c7 --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/target_pool.go @@ -0,0 +1,30 @@ +package targetpool + +import ( + addtarget "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool/add-target" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool/describe" + removetarget "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool/remove-target" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "target-pool", + Short: "Provides functionality for target pools", + Long: "Provides functionality for target pools.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(addtarget.NewCmd(p)) + cmd.AddCommand(removetarget.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) +} diff --git a/internal/cmd/load-balancer/update/update.go b/internal/cmd/load-balancer/update/update.go new file mode 100644 index 000000000..ba10a2d7f --- /dev/null +++ b/internal/cmd/load-balancer/update/update.go @@ -0,0 +1,136 @@ +package update + +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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +const ( + loadBalancerNameArg = "LOAD_BALANCER_NAME" + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LoadBalancerName string + Payload loadbalancer.UpdateLoadBalancerPayload +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", loadBalancerNameArg), + Short: "Updates a Load Balancer", + Long: fmt.Sprintf("%s\n%s\n%s", + "Updates a load balancer.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/load-balancer/version/v1#tag/Load-Balancer/operation/APIService_UpdateLoadBalancer for information regarding the payload structure.", + ), + Args: args.SingleArg(loadBalancerNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Update a load balancer with name "xxx", using an API payload sourced from the file "./payload.json"`, + "$ stackit load-balancer update xxx --payload @./payload.json"), + examples.NewExample( + `Update a load balancer with name "xxx", using an API payload provided as a JSON string`, + `$ stackit load-balancer update xxx --payload "{...}"`), + examples.NewExample( + `Generate a payload with the current values of an existing load balancer xxx, and adapt it with custom values for the different configuration options`, + `$ stackit load-balancer generate-payload --lb-name xxx > ./payload.json`, + ``, + `$ stackit load-balancer update xxx --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update load balancer %q?", model.LoadBalancerName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update load balancer: %w", err) + } + + // The API has no status to wait on, so async mode is default + p.Info("Updated load balancer with name %q\n", model.LoadBalancerName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json`) + + err := flags.MarkFlagsRequired(cmd, payloadFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + loadBalancerName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadString := flags.FlagToStringValue(p, cmd, payloadFlag) + var payload loadbalancer.UpdateLoadBalancerPayload + err := json.Unmarshal([]byte(payloadString), &payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LoadBalancerName: loadBalancerName, + Payload: payload, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiUpdateLoadBalancerRequest { + req := apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.LoadBalancerName) + + req = req.UpdateLoadBalancerPayload(model.Payload) + return req +} diff --git a/internal/cmd/load-balancer/update/update_test.go b/internal/cmd/load-balancer/update/update_test.go new file mode 100644 index 000000000..ea03f5932 --- /dev/null +++ b/internal/cmd/load-balancer/update/update_test.go @@ -0,0 +1,377 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "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/loadbalancer" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() +var testLoadBalancerName = "loadBalancer" + +var testPayload = loadbalancer.UpdateLoadBalancerPayload{ + ExternalAddress: utils.Ptr(""), + + Listeners: &[]loadbalancer.Listener{ + { + DisplayName: utils.Ptr(""), + Port: utils.Ptr(int64(0)), + Protocol: utils.Ptr(""), + ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ + { + Name: utils.Ptr(""), + }, + }, + TargetPool: utils.Ptr(""), + Tcp: &loadbalancer.OptionsTCP{ + IdleTimeout: utils.Ptr(""), + }, + Udp: &loadbalancer.OptionsUDP{ + IdleTimeout: utils.Ptr(""), + }, + }, + }, + Name: utils.Ptr(""), + Networks: &[]loadbalancer.Network{ + { + NetworkId: utils.Ptr(""), + Role: utils.Ptr(""), + }, + }, + Options: &loadbalancer.LoadBalancerOptions{ + AccessControl: &loadbalancer.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: &[]string{ + "", + }, + }, + EphemeralAddress: utils.Ptr(false), + Observability: &loadbalancer.LoadbalancerOptionObservability{ + Logs: &loadbalancer.LoadbalancerOptionLogs{ + CredentialsRef: utils.Ptr(""), + PushUrl: utils.Ptr(""), + }, + Metrics: &loadbalancer.LoadbalancerOptionMetrics{ + CredentialsRef: utils.Ptr(""), + PushUrl: utils.Ptr(""), + }, + }, + PrivateNetworkOnly: utils.Ptr(false), + }, + TargetPools: &[]loadbalancer.TargetPool{ + { + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + HealthyThreshold: utils.Ptr(int64(0)), + Interval: utils.Ptr(""), + IntervalJitter: utils.Ptr(""), + Timeout: utils.Ptr(""), + UnhealthyThreshold: utils.Ptr(int64(0)), + }, + Name: utils.Ptr(""), + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(false), + }, + TargetPort: utils.Ptr(int64(0)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(""), + Ip: utils.Ptr(""), + }, + }, + }, + }, + Version: utils.Ptr(""), +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testLoadBalancerName, + } + 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, + payloadFlag: ` +{ + "externalAddress": "", + "listeners": [ + { + "displayName": "", + "port": 0, + "protocol": "", + "serverNameIndicators": [ + { + "name": "" + } + ], + "targetPool": "", + "tcp": { + "idleTimeout": "" + }, + "udp": { + "idleTimeout": "" + } + } + ], + "name": "", + "networks": [ + { + "networkId": "", + "role": "" + } + ], + "options": { + "accessControl": { + "allowedSourceRanges": [ + "" + ] + }, + "ephemeralAddress": false, + "observability": { + "logs": { + "credentialsRef": "", + "pushUrl": "" + }, + "metrics": { + "credentialsRef": "", + "pushUrl": "" + } + }, + "privateNetworkOnly": false + }, + "targetPools": [ + { + "activeHealthCheck": { + "healthyThreshold": 0, + "interval": "", + "intervalJitter": "", + "timeout": "", + "unhealthyThreshold": 0 + }, + "name": "", + "sessionPersistence": { + "useSourceIpAddress": false + }, + "targetPort": 0, + "targets": [ + { + "displayName": "", + "ip": "" + } + ] + } + ], + "version": "" +} +`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + LoadBalancerName: testLoadBalancerName, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateLoadBalancerRequest)) loadbalancer.ApiUpdateLoadBalancerRequest { + request := testClient.UpdateLoadBalancer(testCtx, testProjectId, testLoadBalancerName) + request = request.UpdateLoadBalancerPayload(testPayload) + 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: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + }, + { + description: "payload missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: false, + }, + { + description: "payload is empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 loadbalancer.ApiUpdateLoadBalancerRequest + isValid bool + }{ + { + 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/root.go b/internal/cmd/root.go index b3f546347..5845d0c80 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -11,6 +11,7 @@ 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" + loadbalancer "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer" "github.com/stackitcloud/stackit-cli/internal/cmd/logme" "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex" @@ -106,6 +107,7 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(config.NewCmd(p)) cmd.AddCommand(curl.NewCmd(p)) cmd.AddCommand(dns.NewCmd(p)) + cmd.AddCommand(loadbalancer.NewCmd(p)) cmd.AddCommand(logme.NewCmd(p)) cmd.AddCommand(mariadb.NewCmd(p)) cmd.AddCommand(mongodbflex.NewCmd(p)) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index c4d7742a4..fa367857d 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -20,6 +20,7 @@ const ( ArgusCustomEndpointKey = "argus_custom_endpoint" AuthorizationCustomEndpointKey = "authorization_custom_endpoint" DNSCustomEndpointKey = "dns_custom_endpoint" + LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint" LogMeCustomEndpointKey = "logme_custom_endpoint" MariaDBCustomEndpointKey = "mariadb_custom_endpoint" MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint" @@ -53,6 +54,7 @@ var ConfigKeys = []string{ VerbosityKey, DNSCustomEndpointKey, + LoadBalancerCustomEndpointKey, LogMeCustomEndpointKey, MariaDBCustomEndpointKey, ObjectStorageCustomEndpointKey, diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go index 92fc9a4cb..4ca7ba433 100644 --- a/internal/pkg/print/print.go +++ b/internal/pkg/print/print.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "syscall" "log/slog" "os" @@ -15,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "golang.org/x/term" ) type Level string @@ -156,6 +158,19 @@ func (p *Printer) PromptForEnter(prompt string) error { return errAborted } +// Prompts the user for a password. +// +// Returns the password that was given, otherwise returns error +func (p *Printer) PromptForPassword(prompt string) (string, error) { + p.Cmd.PrintErr(prompt) + defer p.Outputln("") + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return string(bytePassword), nil +} + // Shows the content in the command's stdout using the "less" command // If output format is set to none, it does nothing func (p *Printer) PagerDisplay(content string) error { diff --git a/internal/pkg/services/load-balancer/client/client.go b/internal/pkg/services/load-balancer/client/client.go new file mode 100644 index 000000000..5a8aa31e4 --- /dev/null +++ b/internal/pkg/services/load-balancer/client/client.go @@ -0,0 +1,46 @@ +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/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +func ConfigureClient(p *print.Printer) (*loadbalancer.APIClient, error) { + var err error + var apiClient *loadbalancer.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.LoadBalancerCustomEndpointKey) + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } else { + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err = loadbalancer.NewAPIClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/load-balancer/utils/utils.go b/internal/pkg/services/load-balancer/utils/utils.go new file mode 100644 index 000000000..0f173b251 --- /dev/null +++ b/internal/pkg/services/load-balancer/utils/utils.go @@ -0,0 +1,132 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +type LoadBalancerClient interface { + GetCredentialsExecute(ctx context.Context, projectId, credentialsRef string) (*loadbalancer.GetCredentialsResponse, error) + GetLoadBalancerExecute(ctx context.Context, projectId, name string) (*loadbalancer.LoadBalancer, error) + UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest +} + +func GetCredentialsDisplayName(ctx context.Context, apiClient LoadBalancerClient, projectId, credentialsRef string) (string, error) { + resp, err := apiClient.GetCredentialsExecute(ctx, projectId, credentialsRef) + if err != nil { + return "", fmt.Errorf("get Load Balancer credentials: %w", err) + } + return *resp.Credential.DisplayName, nil +} + +func GetLoadBalancerTargetPool(ctx context.Context, apiClient LoadBalancerClient, projectId, loadBalancerName, targetPoolName string) (*loadbalancer.TargetPool, error) { + resp, err := apiClient.GetLoadBalancerExecute(ctx, projectId, loadBalancerName) + if err != nil { + return nil, fmt.Errorf("get load balancer: %w", err) + } + + if resp == nil { + return nil, fmt.Errorf("no load balancer found") + } + if resp.TargetPools == nil { + return nil, fmt.Errorf("no target pools found") + } + + targetPool := FindLoadBalancerTargetPoolByName(*resp.TargetPools, targetPoolName) + if targetPool == nil { + return nil, fmt.Errorf("target pool not found") + } + return targetPool, nil +} + +func FindLoadBalancerTargetPoolByName(targetPools []loadbalancer.TargetPool, targetPoolName string) *loadbalancer.TargetPool { + if targetPools == nil { + return nil + } + for _, targetPool := range targetPools { + if targetPool.Name != nil && *targetPool.Name == targetPoolName { + return &targetPool + } + } + return nil +} + +func FindLoadBalancerListenerByTargetPool(listeners []loadbalancer.Listener, targetPoolName string) *loadbalancer.Listener { + if listeners == nil { + return nil + } + for _, listener := range listeners { + if listener.TargetPool != nil && *listener.TargetPool == targetPoolName { + return &listener + } + } + return nil +} + +func AddTargetToTargetPool(targetPool *loadbalancer.TargetPool, target *loadbalancer.Target) error { + if targetPool == nil { + return fmt.Errorf("target pool is nil") + } + if target == nil { + return fmt.Errorf("target is nil") + } + if targetPool.Targets == nil { + targetPool.Targets = &[]loadbalancer.Target{*target} + return nil + } + *targetPool.Targets = append(*targetPool.Targets, *target) + return nil +} + +func RemoveTargetFromTargetPool(targetPool *loadbalancer.TargetPool, ip string) error { + if targetPool == nil { + return fmt.Errorf("target pool is nil") + } + if targetPool.Targets == nil { + return fmt.Errorf("no targets found") + } + targets := *targetPool.Targets + for i, target := range targets { + if target.Ip != nil && *target.Ip == ip { + newTargets := targets[:i] + newTargets = append(newTargets, targets[i+1:]...) + *targetPool.Targets = newTargets + return nil + } + } + return fmt.Errorf("target not found") +} + +func ToPayloadTargetPool(targetPool *loadbalancer.TargetPool) *loadbalancer.UpdateTargetPoolPayload { + if targetPool == nil { + return nil + } + return &loadbalancer.UpdateTargetPoolPayload{ + Name: targetPool.Name, + ActiveHealthCheck: targetPool.ActiveHealthCheck, + SessionPersistence: targetPool.SessionPersistence, + TargetPort: targetPool.TargetPort, + Targets: targetPool.Targets, + } +} + +func GetTargetName(ctx context.Context, apiClient LoadBalancerClient, projectId, loadBalancerName, targetPoolName, targetIp string) (string, error) { + targetPool, err := GetLoadBalancerTargetPool(ctx, apiClient, projectId, loadBalancerName, targetPoolName) + if err != nil { + return "", fmt.Errorf("get target pool: %w", err) + } + if targetPool.Targets == nil { + return "", fmt.Errorf("no targets found") + } + for _, target := range *targetPool.Targets { + if target.Ip != nil && *target.Ip == targetIp { + if target.DisplayName == nil { + return "", fmt.Errorf("nil target display name") + } + return *target.DisplayName, nil + } + } + return "", fmt.Errorf("target not found") +} diff --git a/internal/pkg/services/load-balancer/utils/utils_test.go b/internal/pkg/services/load-balancer/utils/utils_test.go new file mode 100644 index 000000000..2d4d96c0d --- /dev/null +++ b/internal/pkg/services/load-balancer/utils/utils_test.go @@ -0,0 +1,795 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" +) + +var ( + testProjectId = uuid.NewString() +) + +const ( + testCredentialsRef = "credentials-ref" + testCredentialsDisplayName = "credentials-name" + testLoadBalancerName = "my-load-balancer" +) + +type loadBalancerClientMocked struct { + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer +} + +func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { + if m.getCredentialsFails { + return nil, fmt.Errorf("could not get credentials") + } + return m.getCredentialsResp, nil +} + +func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) { + if m.getLoadBalancerFails { + return nil, fmt.Errorf("could not get load balancer") + } + return m.getLoadBalancerResp, nil +} + +func (m *loadBalancerClientMocked) UpdateTargetPool(_ context.Context, _, _, _ string) loadbalancer.ApiUpdateTargetPoolRequest { + return loadbalancer.ApiUpdateTargetPoolRequest{} +} + +func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer { + lb := loadbalancer.LoadBalancer{ + Name: utils.Ptr(testLoadBalancerName), + TargetPools: &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + }, + }, + { + Name: utils.Ptr("target-pool-2"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("6.7.8.9"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("9.8.7.6"), + }, + }, + }, + }, + } + + for _, mod := range mods { + mod(&lb) + } + return &lb +} + +func fixtureTargets(mod ...func(*[]loadbalancer.Target)) *[]loadbalancer.Target { + targets := &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("2.2.2.2"), + }, + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("6.6.6.6"), + }, + } + + for _, m := range mod { + m(targets) + } + + return targets +} + +func TestGetCredentialsDisplayName(t *testing.T) { + tests := []struct { + description string + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getCredentialsResp: &loadbalancer.GetCredentialsResponse{ + Credential: &loadbalancer.CredentialsResponse{ + DisplayName: utils.Ptr(testCredentialsDisplayName), + }, + }, + isValid: true, + expectedOutput: testCredentialsDisplayName, + }, + { + description: "get credentials fails", + getCredentialsFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getCredentialsFails: tt.getCredentialsFails, + getCredentialsResp: tt.getCredentialsResp, + } + + output, err := GetCredentialsDisplayName(context.Background(), client, testProjectId, testCredentialsRef) + + 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 TestGetLoadBalancerTargetPool(t *testing.T) { + tests := []struct { + description string + targetPoolName string + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + isValid bool + expectedOutput *loadbalancer.TargetPool + }{ + { + description: "base", + targetPoolName: "target-pool-1", + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: true, + expectedOutput: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + }, + }, + }, + { + description: "target pool not found", + targetPoolName: "target-pool-non-existent", + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "no target pools", + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = &[]loadbalancer.TargetPool{} + }), + isValid: false, + }, + { + description: "nil target pools", + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = nil + }), + isValid: false, + }, + { + description: "get load balancer fails", + getLoadBalancerFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getLoadBalancerFails: tt.getLoadBalancerFails, + getLoadBalancerResp: tt.getLoadBalancerResp, + } + + output, err := GetLoadBalancerTargetPool(context.Background(), client, testProjectId, testLoadBalancerName, tt.targetPoolName) + + 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 + } + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFindLoadBalancerTargetPoolByName(t *testing.T) { + tests := []struct { + description string + targetPools []loadbalancer.TargetPool + targetPoolName string + expectedTargetPool *loadbalancer.TargetPool + }{ + { + description: "base", + targetPools: []loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + }, + { + Name: utils.Ptr("target-pool-2"), + }, + }, + targetPoolName: "target-pool-1", + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + }, + }, + { + description: "target pool not found", + targetPools: []loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + }, + { + Name: utils.Ptr("target-pool-2"), + }, + }, + targetPoolName: "target-pool-3", + expectedTargetPool: nil, + }, + { + description: "nil target pools", + targetPools: nil, + targetPoolName: "target-pool-1", + expectedTargetPool: nil, + }, + { + description: "no target pools", + targetPools: []loadbalancer.TargetPool{}, + targetPoolName: "target-pool-1", + expectedTargetPool: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := FindLoadBalancerTargetPoolByName(tt.targetPools, tt.targetPoolName) + + diff := cmp.Diff(output, tt.expectedTargetPool) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFindLoadBalancerListenerByTargetPool(t *testing.T) { + tests := []struct { + description string + listeners []loadbalancer.Listener + targetPoolName string + expected *loadbalancer.Listener + }{ + { + description: "base", + listeners: []loadbalancer.Listener{ + { + TargetPool: utils.Ptr("target-pool-1"), + }, + { + TargetPool: utils.Ptr("target-pool-2"), + }, + }, + targetPoolName: "target-pool-1", + expected: &loadbalancer.Listener{ + TargetPool: utils.Ptr("target-pool-1"), + }, + }, + { + description: "listener not found", + listeners: []loadbalancer.Listener{ + { + TargetPool: utils.Ptr("target-pool-1"), + }, + { + TargetPool: utils.Ptr("target-pool-2"), + }, + }, + targetPoolName: "target-pool-3", + expected: nil, + }, + { + description: "nil listeners", + listeners: nil, + targetPoolName: "target-pool-1", + expected: nil, + }, + { + description: "no listeners", + listeners: []loadbalancer.Listener{}, + targetPoolName: "target-pool-1", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := FindLoadBalancerListenerByTargetPool(tt.listeners, tt.targetPoolName) + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestAddTargetToTargetPool(t *testing.T) { + tests := []struct { + description string + targetPool *loadbalancer.TargetPool + target *loadbalancer.Target + isValid bool + expectedTargetPool *loadbalancer.TargetPool + }{ + { + description: "base", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("6.6.6.6"), + }, + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("6.6.6.6"), + }, + }, + }, + }, + { + description: "no target pool targets", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{}, + }, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + }, + }, + }, + { + description: "nil target pool targets", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: nil, + }, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + }, + }, + }, + { + description: "nil target pool", + targetPool: nil, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + expectedTargetPool: nil, + }, + { + description: "nil new target", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + target: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := AddTargetToTargetPool(tt.targetPool, tt.target) + + 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 + } + diff := cmp.Diff(tt.targetPool, tt.expectedTargetPool) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestRemoveTargetFromTargetPool(t *testing.T) { + tests := []struct { + description string + targetPool *loadbalancer.TargetPool + targetIp string + isValid bool + expectedTargetPool *loadbalancer.TargetPool + }{ + { + description: "remove first target", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: fixtureTargets(), + }, + targetIp: "1.2.3.4", + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("2.2.2.2"), + }, + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("6.6.6.6"), + }, + }, + }, + }, + { + description: "remove last target", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: fixtureTargets(), + }, + targetIp: "6.6.6.6", + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("2.2.2.2"), + }, + }, + }, + }, + { + description: "remove middle target", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: fixtureTargets(), + }, + targetIp: "2.2.2.2", + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("6.6.6.6"), + }, + }, + }, + }, + { + description: "remove only target", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + targetIp: "1.2.3.4", + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{}, + }, + }, + { + description: "no target pool targets", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{}, + }, + targetIp: "2.2.2.2", + isValid: false, + }, + { + description: "nil target pool targets", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: nil, + }, + targetIp: "2.2.2.2", + isValid: false, + }, + { + description: "nil target pool", + targetPool: nil, + targetIp: "2.2.2.2", + expectedTargetPool: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := RemoveTargetFromTargetPool(tt.targetPool, tt.targetIp) + + 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 + } + diff := cmp.Diff(tt.targetPool, tt.expectedTargetPool) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestToPayloadTargetPool(t *testing.T) { + tests := []struct { + description string + input *loadbalancer.TargetPool + expected *loadbalancer.UpdateTargetPoolPayload + }{ + { + description: "base", + input: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + expected: &loadbalancer.UpdateTargetPoolPayload{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + }, + { + description: "nil target pool", + input: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := ToPayloadTargetPool(tt.input) + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Errorf("expected output to be %+v, got %+v", tt.expected, output) + } + }) + } +} + +func TestGetTargetName(t *testing.T) { + tests := []struct { + description string + targetPoolName string + targetIp string + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + isValid bool + expectedOutput string + }{ + { + description: "base", + targetPoolName: "target-pool-1", + targetIp: "1.2.3.4", + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: true, + expectedOutput: "target-1", + }, + { + description: "target not found", + targetPoolName: "target-pool-1", + targetIp: "9.9.9.9", + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "no targets", + targetPoolName: "target-pool-1", + targetIp: "1.2.3.4", + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{}, + }, + } + }), + isValid: false, + }, + { + description: "nil targets", + targetPoolName: "target-pool-1", + targetIp: "1.2.3.4", + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + Targets: nil, + }, + } + }), + isValid: false, + }, + { + description: "nil target name", + targetPoolName: "target-pool-1", + targetIp: "1.2.3.4", + getLoadBalancerResp: fixtureLoadBalancer( + func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: nil, + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + } + }), + isValid: false, + }, + { + description: "get target pool fails", + targetPoolName: "target-pool-1", + targetIp: "1.2.3.4", + getLoadBalancerFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getLoadBalancerResp: tt.getLoadBalancerResp, + } + + output, err := GetTargetName(context.Background(), client, testProjectId, testLoadBalancerName, tt.targetPoolName, tt.targetIp) + + 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) + } + }) + } +}