diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index fab1825e5..a0c177662 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,6 +42,7 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake * [stackit beta kms](./stackit_beta_kms.md) - Provides functionality for KMS * [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) diff --git a/docs/stackit_beta_edge-cloud.md b/docs/stackit_beta_edge-cloud.md new file mode 100644 index 000000000..161433446 --- /dev/null +++ b/docs/stackit_beta_edge-cloud.md @@ -0,0 +1,37 @@ +## stackit beta edge-cloud + +Provides functionality for edge services. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) services. + +``` +stackit beta edge-cloud [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for edge kubeconfig. +* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for edge service plans. +* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for edge service token. + diff --git a/docs/stackit_beta_edge-cloud_instance.md b/docs/stackit_beta_edge-cloud_instance.md new file mode 100644 index 000000000..853ac56f0 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance.md @@ -0,0 +1,38 @@ +## stackit beta edge-cloud instance + +Provides functionality for edge instances. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) instance management. + +``` +stackit beta edge-cloud instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud instance" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud instance create](./stackit_beta_edge-cloud_instance_create.md) - Creates an edge instance +* [stackit beta edge-cloud instance delete](./stackit_beta_edge-cloud_instance_delete.md) - Deletes an edge instance +* [stackit beta edge-cloud instance describe](./stackit_beta_edge-cloud_instance_describe.md) - Describes an edge instance +* [stackit beta edge-cloud instance list](./stackit_beta_edge-cloud_instance_list.md) - Lists edge instances +* [stackit beta edge-cloud instance update](./stackit_beta_edge-cloud_instance_update.md) - Updates an edge instance + diff --git a/docs/stackit_beta_edge-cloud_instance_create.md b/docs/stackit_beta_edge-cloud_instance_create.md new file mode 100644 index 000000000..78c123ec1 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_create.md @@ -0,0 +1,43 @@ +## stackit beta edge-cloud instance create + +Creates an edge instance + +### Synopsis + +Creates a STACKIT Edge Cloud (STEC) instance. The instance will take a moment to become fully functional. + +``` +stackit beta edge-cloud instance create [flags] +``` + +### Examples + +``` + Creates an edge instance with the name "xxx" and plan-id "yyy" + $ stackit beta edge-cloud instance create --name "xxx" --plan-id "yyy" +``` + +### Options + +``` + -d, --description string A user chosen description to distinguish multiple instances. + -h, --help Help for "stackit beta edge-cloud instance create" + -n, --name string The displayed name to distinguish multiple instances. + --plan-id string Service Plan configures the size of the Instance. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_delete.md b/docs/stackit_beta_edge-cloud_instance_delete.md new file mode 100644 index 000000000..b8aa5834d --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_delete.md @@ -0,0 +1,45 @@ +## stackit beta edge-cloud instance delete + +Deletes an edge instance + +### Synopsis + +Deletes a STACKIT Edge Cloud (STEC) instance. The instance will be deleted permanently. + +``` +stackit beta edge-cloud instance delete [flags] +``` + +### Examples + +``` + Delete an edge instance with id "xxx" + $ stackit beta edge-cloud instance delete --id "xxx" + + Delete an edge instance with name "xxx" + $ stackit beta edge-cloud instance delete --name "xxx" +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud instance delete" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_describe.md b/docs/stackit_beta_edge-cloud_instance_describe.md new file mode 100644 index 000000000..534bc9cf0 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_describe.md @@ -0,0 +1,45 @@ +## stackit beta edge-cloud instance describe + +Describes an edge instance + +### Synopsis + +Describes a STACKIT Edge Cloud (STEC) instance. + +``` +stackit beta edge-cloud instance describe [flags] +``` + +### Examples + +``` + Describe an edge instance with id "xxx" + $ stackit beta edge-cloud instance describe --id + + Describe an edge instance with name "xxx" + $ stackit beta edge-cloud instance describe --name +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud instance describe" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_list.md b/docs/stackit_beta_edge-cloud_instance_list.md new file mode 100644 index 000000000..e605d6f64 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_list.md @@ -0,0 +1,44 @@ +## stackit beta edge-cloud instance list + +Lists edge instances + +### Synopsis + +Lists STACKIT Edge Cloud (STEC) instances of a project. + +``` +stackit beta edge-cloud instance list [flags] +``` + +### Examples + +``` + Lists all edge instances of a given project + $ stackit beta edge-cloud instance list + + Lists all edge instances of a given project and limits the output to two instances + $ stackit beta edge-cloud instance list --limit 2 +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud instance list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_update.md b/docs/stackit_beta_edge-cloud_instance_update.md new file mode 100644 index 000000000..9f3cb39b7 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_update.md @@ -0,0 +1,50 @@ +## stackit beta edge-cloud instance update + +Updates an edge instance + +### Synopsis + +Updates a STACKIT Edge Cloud (STEC) instance. + +``` +stackit beta edge-cloud instance update [flags] +``` + +### Examples + +``` + Updates the description of an edge instance with id "xxx" + $ stackit beta edge-cloud instance update --id "xxx" --description "yyy" + + Updates the plan of an edge instance with name "xxx" + $ stackit beta edge-cloud instance update --name "xxx" --plan-id "yyy" + + Updates the description and plan of an edge instance with id "xxx" + $ stackit beta edge-cloud instance update --id "xxx" --description "yyy" --plan-id "zzz" +``` + +### Options + +``` + -d, --description string A user chosen description to distinguish multiple instances. + -h, --help Help for "stackit beta edge-cloud instance update" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. + --plan-id string Service Plan configures the size of the Instance. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_kubeconfig.md b/docs/stackit_beta_edge-cloud_kubeconfig.md new file mode 100644 index 000000000..be5078f00 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_kubeconfig.md @@ -0,0 +1,34 @@ +## stackit beta edge-cloud kubeconfig + +Provides functionality for edge kubeconfig. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) kubeconfig management. + +``` +stackit beta edge-cloud kubeconfig [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud kubeconfig" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud kubeconfig create](./stackit_beta_edge-cloud_kubeconfig_create.md) - Creates or updates a local kubeconfig file of an edge instance + diff --git a/docs/stackit_beta_edge-cloud_kubeconfig_create.md b/docs/stackit_beta_edge-cloud_kubeconfig_create.md new file mode 100644 index 000000000..2d9a5ad40 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_kubeconfig_create.md @@ -0,0 +1,61 @@ +## stackit beta edge-cloud kubeconfig create + +Creates or updates a local kubeconfig file of an edge instance + +### Synopsis + +Creates or updates a local kubeconfig file of a STACKIT Edge Cloud (STEC) instance. If the config exists in the kubeconfig file, the information will be updated. + +By default, the kubeconfig information of the edge instance is merged into the current kubeconfig file which is determined by Kubernetes client logic. If the kubeconfig file doesn't exist, a new one will be created. +You can override this behavior by specifying a custom filepath with the --filepath flag or disable writing with the --disable-writing flag. +An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 3600 seconds. +Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units. + +``` +stackit beta edge-cloud kubeconfig create [flags] +``` + +### Examples + +``` + Create or update a kubeconfig for the edge instance with id "xxx". If the config exists in the kubeconfig file, the information will be updated. + $ stackit beta edge-cloud kubeconfig create --id "xxx" + + Create or update a kubeconfig for the edge instance with name "xxx" in a custom filepath. + $ stackit beta edge-cloud kubeconfig create --name "xxx" --filepath "yyy" + + Get a kubeconfig for the edge instance with name "xxx" without writing it to a file and format the output as json. + $ stackit beta edge-cloud kubeconfig create --name "xxx" --disable-writing --output-format json + + Create a kubeconfig for the edge instance with id "xxx". This will replace your current kubeconfig file. + $ stackit beta edge-cloud kubeconfig create --id "xxx" --overwrite +``` + +### Options + +``` + --disable-writing Disable writing the kubeconfig to a file. + -e, --expiration string Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h. + -f, --filepath string Path to the kubeconfig file. A default is chosen by Kubernetes if not set. + -h, --help Help for "stackit beta edge-cloud kubeconfig create" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. + --overwrite Force overwrite the kubeconfig file if it exists. + --switch-context Switch to the context in the kubeconfig file to the new context. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for edge kubeconfig. + diff --git a/docs/stackit_beta_edge-cloud_plans.md b/docs/stackit_beta_edge-cloud_plans.md new file mode 100644 index 000000000..c58e5a8e1 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_plans.md @@ -0,0 +1,34 @@ +## stackit beta edge-cloud plans + +Provides functionality for edge service plans. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) service plan management. + +``` +stackit beta edge-cloud plans [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud plans" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud plans list](./stackit_beta_edge-cloud_plans_list.md) - Lists available edge service plans + diff --git a/docs/stackit_beta_edge-cloud_plans_list.md b/docs/stackit_beta_edge-cloud_plans_list.md new file mode 100644 index 000000000..a57c7e197 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_plans_list.md @@ -0,0 +1,44 @@ +## stackit beta edge-cloud plans list + +Lists available edge service plans + +### Synopsis + +Lists available STACKIT Edge Cloud (STEC) service plans of a project + +``` +stackit beta edge-cloud plans list [flags] +``` + +### Examples + +``` + Lists all edge plans for a given project + $ stackit beta edge-cloud plan list + + Lists all edge plans for a given project and limits the output to two plans + $ stackit beta edge-cloud plan list --limit 2 +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud plans list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for edge service plans. + diff --git a/docs/stackit_beta_edge-cloud_token.md b/docs/stackit_beta_edge-cloud_token.md new file mode 100644 index 000000000..ba7fe0b3a --- /dev/null +++ b/docs/stackit_beta_edge-cloud_token.md @@ -0,0 +1,34 @@ +## stackit beta edge-cloud token + +Provides functionality for edge service token. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) token management. + +``` +stackit beta edge-cloud token [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud token" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud token create](./stackit_beta_edge-cloud_token_create.md) - Creates a token for an edge instance + diff --git a/docs/stackit_beta_edge-cloud_token_create.md b/docs/stackit_beta_edge-cloud_token_create.md new file mode 100644 index 000000000..4d96d548c --- /dev/null +++ b/docs/stackit_beta_edge-cloud_token_create.md @@ -0,0 +1,49 @@ +## stackit beta edge-cloud token create + +Creates a token for an edge instance + +### Synopsis + +Creates a token for a STACKIT Edge Cloud (STEC) instance. + +An expiration time can be set for the token. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 3600 seconds. +Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units. + +``` +stackit beta edge-cloud token create [flags] +``` + +### Examples + +``` + Create a token for the edge instance with id "xxx". + $ stackit beta edge-cloud token create --id "xxx" + + Create a token for the edge instance with name "xxx". The token will be valid for one day. + $ stackit beta edge-cloud token create --name "xxx" --expiration 1d +``` + +### Options + +``` + -e, --expiration string Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h. + -h, --help Help for "stackit beta edge-cloud token create" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for edge service token. + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index b9a4e81a1..211bc1113 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -32,6 +32,7 @@ stackit config set [flags] --allowed-url-domain string Domain name, used for the verification of the URLs that are given in the custom identity provider endpoint and "STACKIT curl" command --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 + --edge-custom-endpoint string Edge API base URL, used in calls to this API -h, --help Help for "stackit config set" --iaas-custom-endpoint string IaaS API base URL, used in calls to this API --identity-provider-custom-client-id string Identity Provider client ID, used for user authentication diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 9e005607b..07b161c8c 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -30,6 +30,7 @@ stackit config unset [flags] --async Configuration option to run commands asynchronously --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 + --edge-custom-endpoint Edge API base URL. If unset, uses the default base URL -h, --help Help for "stackit config unset" --iaas-custom-endpoint IaaS API base URL. If unset, uses the default base URL --identity-provider-custom-client-id Identity Provider client ID, used for user authentication diff --git a/go.mod b/go.mod index 656654080..819818a8b 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/alb v0.8.0 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3 + github.com/stackitcloud/stackit-sdk-go/services/edge v0.2.0 github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0 github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.1 diff --git a/go.sum b/go.sum index 0d69feaf8..6c2cf3173 100644 --- a/go.sum +++ b/go.sum @@ -608,6 +608,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.11.0 h1:4YFY5PG github.com/stackitcloud/stackit-sdk-go/services/authorization v0.11.0/go.mod h1:v4xdRA5P8Vr+zLdHh+ODgspN0WJG04wLImIJoYjrPK4= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3 h1:KD/FxU/cJIzfyMvwiOvTlSWq87ISENpHNmw/quznGnw= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3/go.mod h1:BNiIZkDqwSV1LkWDjMKxVb9pxQ/HMIsXJ0AQ8pFoAo4= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.2.0 h1:ElmnEg3V4MisAgqqJFxl3nCmKraxbHtN+vv1DNiWYfM= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.2.0/go.mod h1:tFDkVkK+ESBTiH2XIcMPPR/pJJmeqT1VNDghg+ZxfMI= github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 h1:3JKXfI5hdcXcRVBjUZg5qprXG5rDmPnM6dsvplMk/vg= github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1/go.mod h1:3nTaj8IGjNNGYUD2CpuXkXwc5c4giTUmoPggFhjVFxo= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0 h1:U/x0tc487X9msMS5yZYjrBAAKrCx87Trmt0kh8JiARA= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 935791cb9..973a87c21 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -6,6 +6,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs" @@ -43,6 +44,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(sfs.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) + cmd.AddCommand(edge.NewCmd(params)) cmd.AddCommand(intake.NewCmd(params)) cmd.AddCommand(kms.NewCmd(params)) } diff --git a/internal/cmd/beta/edge/edge.go b/internal/cmd/beta/edge/edge.go new file mode 100644 index 000000000..11d1b1e16 --- /dev/null +++ b/internal/cmd/beta/edge/edge.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package edge + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/kubeconfig" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/plans" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/token" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "edge-cloud", + Short: "Provides functionality for edge services.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) services.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(kubeconfig.NewCmd(params)) + cmd.AddCommand(token.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/instance/create/create.go b/internal/cmd/beta/edge/instance/create/create.go new file mode 100755 index 000000000..bc264ddb5 --- /dev/null +++ b/internal/cmd/beta/edge/instance/create/create.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" +) + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an edge instance", + Long: "Creates a STACKIT Edge Cloud (STEC) instance. The instance will take a moment to become fully functional.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Creates an edge instance with the %s "xxx" and %s "yyy"`, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance create --%s "xxx" --%s "yyy"`, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Prompt for confirmation + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a new edge instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + if resp == nil { + return fmt.Errorf("create instance: empty response from API") + } + if resp.Id == nil { + return fmt.Errorf("create instance: instance id missing in response") + } + instanceId := *resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Creating instance") + // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match. + client, ok := apiClient.(*edge.APIClient) + if !ok { + return fmt.Errorf("failed to configure API client") + } + _, err = wait.CreateOrUpdateInstanceWaitHandler(ctx, client, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + + if err != nil { + return fmt.Errorf("wait for edge instance creation: %w", err) + } + s.Stop() + } + + // Handle output to printer + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +// inputModel represents the user input for creating an edge instance. +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName string + Description string + PlanId string +} + +// createRequestSpec captures the details of the request for testing. +type createRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + Payload edge.PostInstancesPayload + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Instance, error) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().StringP(commonInstance.DescriptionFlag, commonInstance.DescriptionShorthand, "", commonInstance.DescriptionUsage) + cmd.Flags().String(commonInstance.PlanIdFlag, "", commonInstance.PlanIdUsage) + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Parse and validate user input then add it to the model + displayNameValue := flags.FlagToStringPointer(p, cmd, commonInstance.DisplayNameFlag) + if err := commonInstance.ValidateDisplayName(displayNameValue); err != nil { + return nil, err + } + + planIdValue := flags.FlagToStringPointer(p, cmd, commonInstance.PlanIdFlag) + if err := commonInstance.ValidatePlanId(planIdValue); err != nil { + return nil, err + } + + descriptionValue := flags.FlagWithDefaultToStringValue(p, cmd, commonInstance.DescriptionFlag) + if err := commonInstance.ValidateDescription(descriptionValue); err != nil { + return nil, err + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: *displayNameValue, + Description: descriptionValue, + PlanId: *planIdValue, + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Instance, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { + req := apiClient.PostInstances(ctx, model.ProjectId, model.Region) + + // Build request payload + payload := edge.PostInstancesPayload{ + DisplayName: &model.DisplayName, + Description: &model.Description, + PlanId: &model.PlanId, + } + req = req.PostInstancesPayload(payload) + + return &createRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Payload: payload, + Execute: req.Execute, + }, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, instance *edge.Instance) error { + if instance == nil { + // This is only to prevent nil pointer deref. + // As long as the API behaves as defined by it's spec, instance can not be empty (HTTP 200 with an empty body) + return commonErr.NewNoInstanceError("") + } + + return p.OutputResult(outputFormat, instance, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s instance for project %q. Instance ID: %q.\n", operationState, projectLabel, utils.PtrString(instance.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/edge/instance/create/create_test.go b/internal/cmd/beta/edge/instance/create/create_test.go new file mode 100755 index 000000000..bbec4b074 --- /dev/null +++ b/internal/cmd/beta/edge/instance/create/create_test.go @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + + testName = "test" + testPlanId = uuid.NewString() + testDescription = "Initial instance description" + testInstanceId = uuid.NewString() +) + +// mockExecutable is a mock for the Executable interface used by the SDK +type mockExecutable struct { + executeFails bool + resp *edge.Instance +} + +func (m *mockExecutable) PostInstancesPayload(_ edge.PostInstancesPayload) edge.ApiPostInstancesRequest { + // This method is needed to satisfy the interface. It allows chaining in buildRequest. + return m +} +func (m *mockExecutable) Execute() (*edge.Instance, error) { + if m.executeFails { + return nil, errors.New("API error") + } + if m.resp != nil { + return m.resp, nil + } + return &edge.Instance{Id: &testInstanceId}, nil +} + +// mockAPIClient is a mock for the client.APIClient interface +type mockAPIClient struct { + postInstancesMock edge.ApiPostInstancesRequest +} + +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + if m.postInstancesMock != nil { + return m.postInstancesMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the client.APIClient interface +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.DisplayNameFlag: testName, + commonInstance.DescriptionFlag: testDescription, + commonInstance.PlanIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: testName, + Description: testDescription, + PlanId: testPlanId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "create success", + want: fixtureInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}, globalflags.GlobalFlagModel{}), + }, + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "name missing", + wantErr: "required flag(s) \"name\" not set", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.DisplayNameFlag) + }), + }, + }, + { + name: "name too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = "this-name-is-way-too-long-for-the-validation" + }), + }, + }, + { + name: "name too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = "in" + }), + }, + }, + { + name: "name invalid", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = "1test" + }), + }, + }, + { + name: "plan invalid", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.PlanIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "description too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DescriptionFlag] = strings.Repeat("a", 257) + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + want *createRequestSpec + }{ + { + name: "success", + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + postInstancesMock: &mockExecutable{}, + }, + }, + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + Payload: edge.PostInstancesPayload{ + DisplayName: &testName, + Description: &testDescription, + PlanId: &testPlanId, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _ := buildRequest(testCtx, tt.args.model, tt.args.client) + + if got != nil { + if got.Execute == nil { + t.Error("expected non-nil Execute function") + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(createRequestSpec{}, "Execute")) + } + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *edge.Instance + args args + }{ + { + name: "create success", + want: &edge.Instance{Id: &testInstanceId}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + postInstancesMock: &mockExecutable{ + resp: &edge.Instance{Id: &testInstanceId}, + }, + }, + }, + }, + { + name: "create API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + postInstancesMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + instance *edge.Instance + projectLabel string + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "no instance", + wantErr: &commonErr.NoInstanceError{}, + args: args{ + model: fixtureInputModel(), + }, + }, + { + name: "output json", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output yaml", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output default", + args: args{ + model: fixtureInputModel(), + instance: &edge.Instance{Id: &testInstanceId}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.model.Async, tt.args.projectLabel, tt.args.instance) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/beta/edge/instance/delete/delete.go b/internal/cmd/beta/edge/instance/delete/delete.go new file mode 100755 index 000000000..d6650e7e6 --- /dev/null +++ b/internal/cmd/beta/edge/instance/delete/delete.go @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/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/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier +} + +// deleteRequestSpec captures the details of a request for testing. +type deleteRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string // Set if deleting by ID + InstanceName string // Set if deleting by Name + + // Execute is a closure that wraps the actual SDK call + Execute func() error +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// InstanceWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type instanceWaiter interface { + WaitWithContext(context.Context) (*edge.Instance, error) +} + +// A function that creates an instance waiter +type instanceWaiterFactory = func(client *edge.APIClient) instanceWaiter + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes an edge instance", + Long: "Deletes a STACKIT Edge Cloud (STEC) instance. The instance will be deleted permanently.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Delete an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance delete --%s "xxx"`, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Delete an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance delete --%s "xxx"`, commonInstance.DisplayNameFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Prompt for confirmation + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the edge instance %q of project %q?", model.identifier.Value, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + err = run(ctx, model, apiClient) + if err != nil { + return err + } + + // Wait for async operation, if async mode not enabled + operationState := "Triggered deletion of" + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Deleting instance") + // Determine identifier and waiter to use + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return err + } + // The waiter factory needs a concrete client type. We can safely cast here as the real implementation will always match. + client, ok := apiClient.(*edge.APIClient) + if !ok { + return fmt.Errorf("failed to configure API client") + } + waiter := waiterFactory(client) + + if _, err = waiter.WaitWithContext(ctx); err != nil { + return fmt.Errorf("wait for edge instance deletion: %w", err) + } + operationState = "Deleted" + s.Stop() + } + + params.Printer.Info("%s instance with %q %q of project %q.\n", operationState, model.identifier.Flag, model.identifier.Value, projectLabel) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) error { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + if err := spec.Execute(); err != nil { + return cliErr.NewRequestFailedError(err) + } + + return nil +} + +// buildRequest constructs the spec that can be tested. +// It handles the logic of choosing between DeleteInstance and DeleteInstanceByName. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*deleteRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &deleteRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + } + + // Switch the concrete client based on the identifier flag used + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + req := apiClient.DeleteInstanceByName(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (instanceWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.DeleteInstanceWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.DeleteInstanceByNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} diff --git a/internal/cmd/beta/edge/instance/delete/delete_test.go b/internal/cmd/beta/edge/instance/delete/delete_test.go new file mode 100755 index 000000000..2772b8c97 --- /dev/null +++ b/internal/cmd/beta/edge/instance/delete/delete_test.go @@ -0,0 +1,557 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package delete + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + + testInstanceId = "instance" + testDisplayName = "test" +) + +// mockExecutable implements the SDK delete request interface for testing. +type mockExecutable struct { + executeFails bool + executeNotFound bool +} + +func (m *mockExecutable) Execute() error { + if m.executeNotFound { + return &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + Body: []byte(`{"message":"not found"}`), + } + } + if m.executeFails { + return errors.New("execute failed") + } + return nil +} + +// mockAPIClient provides the minimal API client behavior required by the tests. +type mockAPIClient struct { + deleteInstanceMock edge.ApiDeleteInstanceRequest + deleteInstanceByNameMock edge.ApiDeleteInstanceByNameRequest +} + +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + if m.deleteInstanceMock != nil { + return m.deleteInstanceMock + } + return &mockExecutable{} +} + +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + if m.deleteInstanceByNameMock != nil { + return m.deleteInstanceByNameMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the client.APIClient interface. +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(useDisplayName bool, mods ...func(*inputModel)) *inputModel { + identifier := &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + if useDisplayName { + identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + identifier: identifier, + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureByIdInputModel(mods ...func(*inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(*inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}, globalflags.GlobalFlagModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}, globalflags.GlobalFlagModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instance id empty", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instance id too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instance id too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + wantErr error + }{ + { + name: "delete by id success", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{}, + }, + }, + }, + { + name: "delete by id API error", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{executeFails: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "delete by id not found", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{executeNotFound: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "delete by name success", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{}, + }, + }, + }, + { + name: "delete by name API error", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{executeFails: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "delete by name not found", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{executeNotFound: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "no identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + { + name: "invalid identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unknown", Value: "value"} + }), + client: &mockAPIClient{}, + }, + wantErr: &cliErr.BuildRequestError{}, + }, + { + name: "nil model", + args: args{ + model: nil, + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := run(testCtx, tt.args.model, tt.args.client) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + want *deleteRequestSpec + wantErr error + }{ + { + name: "by id", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{}, + }, + }, + want: &deleteRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + }, + }, + { + name: "by name", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{}, + }, + }, + want: &deleteRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + }, + }, + { + name: "no identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + { + name: "invalid identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unknown", Value: "val"} + }), + client: &mockAPIClient{}, + }, + wantErr: &cliErr.BuildRequestError{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if got != nil { + if got.Execute == nil { + t.Error("expected non-nil Execute function") + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(deleteRequestSpec{}, "Execute")) + } + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + + tests := []struct { + name string + wantErr error + want bool + args args + }{ + { + name: "by id identifier", + want: true, + args: args{ + model: fixtureByIdInputModel(), + }, + }, + { + name: "by name identifier", + want: true, + args: args{ + model: fixtureByNameInputModel(), + }, + }, + { + name: "nil model", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: nil, + }, + }, + { + name: "nil identifier", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + }, + }, + { + name: "invalid identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + want: false, + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unsupported", Value: "value"} + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} diff --git a/internal/cmd/beta/edge/instance/describe/describe.go b/internal/cmd/beta/edge/instance/describe/describe.go new file mode 100755 index 000000000..5a7d85ed6 --- /dev/null +++ b/internal/cmd/beta/edge/instance/describe/describe.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier +} + +// describeRequestSpec captures the details of the request for testing. +type describeRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string // Set if describing by ID + InstanceName string // Set if describing by Name + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Instance, error) +} + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes an edge instance", + Long: "Describes a STACKIT Edge Cloud (STEC) instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Describe an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance describe --%s `, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Describe an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance describe --%s `, commonInstance.DisplayNameFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + // Handle output to printer + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Instance, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +// It handles the logic of choosing between GetInstance and GetInstanceByName. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*describeRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &describeRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + } + + // Switch the concrete client based on the identifier flag used + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + req := apiClient.GetInstance(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + req := apiClient.GetInstanceByName(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + return spec, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, instance *edge.Instance) error { + if instance == nil { + // This is only to prevent nil pointer deref. + // As long as the API behaves as defined by it's spec, instance can not be empty (HTTP 200 with an empty body) + return commonErr.NewNoInstanceError("") + } + + return p.OutputResult(outputFormat, instance, func() error { + table := tables.NewTable() + // Describe: output all fields. Be sure to filter for any non-required fields. + table.AddRow("CREATED", utils.PtrString(instance.Created)) + table.AddSeparator() + table.AddRow("ID", utils.PtrString(instance.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(instance.DisplayName)) + table.AddSeparator() + if instance.HasDescription() { + table.AddRow("DESCRIPTION", utils.PtrString(instance.Description)) + table.AddSeparator() + } + table.AddRow("UI", utils.PtrString(instance.FrontendUrl)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(instance.Status)) + table.AddSeparator() + table.AddRow("PLAN", utils.PtrString(instance.PlanId)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/edge/instance/describe/describe_test.go b/internal/cmd/beta/edge/instance/describe/describe_test.go new file mode 100755 index 000000000..1f08cd0c6 --- /dev/null +++ b/internal/cmd/beta/edge/instance/describe/describe_test.go @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package describe + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + + testInstanceId = "instance" + testDisplayName = "test" +) + +// mockExecutable is a mock for the Executable interface +type mockExecutable struct { + executeFails bool + executeNotFound bool + executeResp *edge.Instance +} + +func (m *mockExecutable) Execute() (*edge.Instance, error) { + if m.executeFails { + return nil, errors.New("API error") + } + if m.executeNotFound { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + return m.executeResp, nil +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct { + getInstanceMock edge.ApiGetInstanceRequest + getInstanceByNameMock edge.ApiGetInstanceByNameRequest +} + +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + if m.getInstanceMock != nil { + return m.getInstanceMock + } + return &mockExecutable{} +} + +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + if m.getInstanceByNameMock != nil { + return m.getInstanceByNameMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instanceId missing", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "instanceId empty", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instanceId too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instanceId too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *edge.Instance + args args + }{ + { + name: "get by id success", + want: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{ + executeResp: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + }, + }, + }, + }, + { + name: "get by name success", + want: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + getInstanceByNameMock: &mockExecutable{ + executeResp: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + }, + }, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{ + executeNotFound: true, + }, + }, + }, + }, + { + name: "get by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "get by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + getInstanceByNameMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type outputArgs struct { + model *inputModel + instance *edge.Instance + } + + tests := []struct { + name string + wantErr error + args outputArgs + }{ + { + name: "no instance", + wantErr: &commonErr.NoInstanceError{}, + args: outputArgs{ + model: fixtureByIdInputModel(), + instance: nil, + }, + }, + { + name: "output json", + args: outputArgs{ + model: fixtureInputModel(false, func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + model.identifier = nil + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output yaml", + args: outputArgs{ + model: fixtureInputModel(false, func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + model.identifier = nil + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output default", + args: outputArgs{ + model: fixtureByIdInputModel(), + instance: &edge.Instance{Id: &testInstanceId}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.instance) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *describeRequestSpec + args args + }{ + { + name: "get by id", + want: &describeRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{}, + }, + }, + }, + { + name: "get by name", + want: &describeRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + getInstanceByNameMock: &mockExecutable{}, + }, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(describeRequestSpec{}, "Execute")) + }) + } +} diff --git a/internal/cmd/beta/edge/instance/instance.go b/internal/cmd/beta/edge/instance/instance.go new file mode 100644 index 000000000..748371cda --- /dev/null +++ b/internal/cmd/beta/edge/instance/instance.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/update" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for edge instances.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) instance management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/instance/list/list.go b/internal/cmd/beta/edge/instance/list/list.go new file mode 100755 index 000000000..ca84cca87 --- /dev/null +++ b/internal/cmd/beta/edge/instance/list/list.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +const ( + limitFlag = "limit" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// listRequestSpec captures the details of the request for testing. +type listRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + Limit *int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.InstanceList, error) +} + +// Command constructor +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists edge instances", + Long: "Lists STACKIT Edge Cloud (STEC) instances of a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all edge instances of a given project`, + `$ stackit beta edge-cloud instance list`), + examples.NewExample( + `Lists all edge instances of a given project and limits the output to two instances`, + fmt.Sprintf(`$ stackit beta edge-cloud instance list --%s 2`, limitFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Parse and validate user input then add it to the model + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) ([]edge.Instance, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + if resp == nil { + return nil, fmt.Errorf("list instances: empty response from API") + } + if resp.Instances == nil { + return nil, fmt.Errorf("list instances: instances missing in response") + } + instances := *resp.Instances + + // Truncate output if limit is set + if spec.Limit != nil && len(instances) > int(*spec.Limit) { + instances = instances[:*spec.Limit] + } + + return instances, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*listRequestSpec, error) { + req := apiClient.GetInstances(ctx, model.ProjectId, model.Region) + + return &listRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Limit: model.Limit, + Execute: req.Execute, + }, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []edge.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + // No instances found for project + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil + } + + // Display instances found for project in a table + table := tables.NewTable() + // List: only output the most important fields. Be sure to filter for any non-required fields. + table.SetHeader("ID", "NAME", "UI", "STATE") + for i := range instances { + instance := instances[i] + table.AddRow( + utils.PtrString(instance.Id), + utils.PtrString(instance.DisplayName), + utils.PtrString(instance.FrontendUrl), + utils.PtrString(instance.Status)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/edge/instance/list/list_test.go b/internal/cmd/beta/edge/instance/list/list_test.go new file mode 100755 index 000000000..2c809d95f --- /dev/null +++ b/internal/cmd/beta/edge/instance/list/list_test.go @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" +) + +// mockExecutable is a mock for the Executable interface +type mockExecutable struct { + executeFails bool + executeResp *edge.InstanceList +} + +func (m *mockExecutable) Execute() (*edge.InstanceList, error) { + if m.executeFails { + return nil, errors.New("API error") + } + + if m.executeResp != nil { + return m.executeResp, nil + } + return &edge.InstanceList{ + Instances: &[]edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + {Id: utils.Ptr("instance-2"), DisplayName: utils.Ptr("nameb")}, + }, + }, nil +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct { + getInstancesMock edge.ApiGetInstancesRequest +} + +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + if m.getInstancesMock != nil { + return m.getInstancesMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "success", + want: fixtureInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "with limit", + want: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "10" + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "limit invalid", + wantErr: "invalid syntax", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + }, + }, + { + name: "limit less than 1", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want []edge.Instance + args args + }{ + { + name: "list success", + want: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + {Id: utils.Ptr("instance-2"), DisplayName: utils.Ptr("nameb")}, + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit", + want: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(1)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit greater than items", + want: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + {Id: utils.Ptr("instance-2"), DisplayName: utils.Ptr("nameb")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(5)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with no items", + want: []edge.Instance{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getInstancesMock: &mockExecutable{ + executeResp: &edge.InstanceList{Instances: &[]edge.Instance{}}, + }, + }, + }, + }, + { + name: "list API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getInstancesMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + instances []edge.Instance + projectLabel string + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "no instance", + args: args{ + model: fixtureInputModel(), + }, + }, + { + name: "output json", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + instances: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output yaml", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + instances: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with instances", + args: args{ + model: fixtureInputModel(), + instances: []edge.Instance{ + { + Id: utils.Ptr("instance-1"), + DisplayName: utils.Ptr("namea"), + FrontendUrl: utils.Ptr("https://example.com"), + }, + { + Id: utils.Ptr("instance-2"), + DisplayName: utils.Ptr("nameb"), + FrontendUrl: utils.Ptr("https://example2.com"), + }, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with no instances", + args: args{ + model: fixtureInputModel(), + instances: []edge.Instance{}, + projectLabel: "test-project", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.projectLabel, tt.args.instances) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *listRequestSpec + args args + }{ + { + name: "success", + want: &listRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getInstancesMock: &mockExecutable{}, + }, + }, + }, + { + name: "success with limit", + want: &listRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + Limit: utils.Ptr(int64(10)), + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + client: &mockAPIClient{ + getInstancesMock: &mockExecutable{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(listRequestSpec{}, "Execute")) + }) + } +} diff --git a/internal/cmd/beta/edge/instance/update/update.go b/internal/cmd/beta/edge/instance/update/update.go new file mode 100755 index 000000000..28ec3437a --- /dev/null +++ b/internal/cmd/beta/edge/instance/update/update.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier + Description *string + PlanId *string +} + +// updateRequestSpec captures the details of the request for testing. +type updateRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string // Set if updating by ID + InstanceName string // Set if updating by Name + Payload edge.UpdateInstancePayload + + // Execute is a closure that wraps the actual SDK call + Execute func() error +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// InstanceWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type instanceWaiter interface { + WaitWithContext(context.Context) (*edge.Instance, error) +} + +// A function that creates an instance waiter +type instanceWaiterFactory = func(client *edge.APIClient) instanceWaiter + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates an edge instance", + Long: "Updates a STACKIT Edge Cloud (STEC) instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Updates the description of an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance update --%s "xxx" --%s "yyy"`, commonInstance.InstanceIdFlag, commonInstance.DescriptionFlag)), + examples.NewExample( + fmt.Sprintf(`Updates the plan of an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance update --%s "xxx" --%s "yyy"`, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)), + examples.NewExample( + fmt.Sprintf(`Updates the description and plan of an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance update --%s "xxx" --%s "yyy" --%s "zzz"`, commonInstance.InstanceIdFlag, commonInstance.DescriptionFlag, commonInstance.PlanIdFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Prompt for confirmation + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update the edge instance %q of project %q?", model.identifier.Value, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + err = run(ctx, model, apiClient) + if err != nil { + return err + } + + // Wait for async operation, if async mode not enabled + operationState := "Triggered update of" + if !model.Async { + // Wait for async operation, if async mode not enabled + // Show spinner while waiting + s := spinner.New(params.Printer) + s.Start("Updating instance") + // Determine identifier and waiter to use + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return err + } + // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match. + client, ok := apiClient.(*edge.APIClient) + if !ok { + return fmt.Errorf("failed to configure API client") + } + waiter := waiterFactory(client) + + if _, err = waiter.WaitWithContext(ctx); err != nil { + return fmt.Errorf("wait for edge instance update: %w", err) + } + operationState = "Updated" + s.Stop() + } + + params.Printer.Info("%s instance with %q %q of project %q.\n", operationState, model.identifier.Flag, model.identifier.Value, projectLabel) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().StringP(commonInstance.DescriptionFlag, commonInstance.DescriptionShorthand, "", commonInstance.DescriptionUsage) + cmd.Flags().StringP(commonInstance.PlanIdFlag, "", "", commonInstance.PlanIdUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) + + // Make sure at least one updatable field is provided, otherwise it would be a no-op + updatedFields := []string{commonInstance.DescriptionFlag, commonInstance.PlanIdFlag} + cmd.MarkFlagsOneRequired(updatedFields...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + if planIdValue := flags.FlagToStringPointer(p, cmd, commonInstance.PlanIdFlag); planIdValue != nil { + if err := commonInstance.ValidatePlanId(planIdValue); err != nil { + return nil, err + } + model.PlanId = planIdValue + } + + if descriptionValue := flags.FlagToStringPointer(p, cmd, commonInstance.DescriptionFlag); descriptionValue != nil { + if err := commonInstance.ValidateDescription(*descriptionValue); err != nil { + return nil, err + } + model.Description = descriptionValue + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) error { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + err = spec.Execute() + if err != nil { + return cliErr.NewRequestFailedError(err) + } + + return nil +} + +// buildRequest constructs the spec that can be tested. +// It handles the logic of choosing between UpdateInstance and UpdateInstanceByName. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*updateRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &updateRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Payload: edge.UpdateInstancePayload{ + Description: model.Description, + PlanId: model.PlanId, + }, + } + + // Switch the concrete client based on the identifier flag used + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + req := apiClient.UpdateInstance(ctx, model.ProjectId, model.Region, model.identifier.Value) + req = req.UpdateInstancePayload(spec.Payload) + spec.Execute = req.Execute + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + req := apiClient.UpdateInstanceByName(ctx, model.ProjectId, model.Region, model.identifier.Value) + req = req.UpdateInstanceByNamePayload(edge.UpdateInstanceByNamePayload{ + Description: spec.Payload.Description, + PlanId: spec.Payload.PlanId, + }) + spec.Execute = req.Execute + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (instanceWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.CreateOrUpdateInstanceWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.CreateOrUpdateInstanceByNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} diff --git a/internal/cmd/beta/edge/instance/update/update_test.go b/internal/cmd/beta/edge/instance/update/update_test.go new file mode 100755 index 000000000..434a74337 --- /dev/null +++ b/internal/cmd/beta/edge/instance/update/update_test.go @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package update + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = "instance" + testDisplayName = "test" + testDescription = "new description" + testPlanId = uuid.NewString() +) + +type mockExecutable struct { + executeFails bool + executeNotFound bool + capturedUpdatePayload *edge.UpdateInstancePayload + capturedUpdateByNamePayload *edge.UpdateInstanceByNamePayload +} + +func (m *mockExecutable) Execute() error { + if m.executeFails { + return errors.New("API error") + } + if m.executeNotFound { + return &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + return nil +} + +func (m *mockExecutable) UpdateInstancePayload(payload edge.UpdateInstancePayload) edge.ApiUpdateInstanceRequest { + if m.capturedUpdatePayload != nil { + *m.capturedUpdatePayload = payload + } + return m +} + +func (m *mockExecutable) UpdateInstanceByNamePayload(payload edge.UpdateInstanceByNamePayload) edge.ApiUpdateInstanceByNameRequest { + if m.capturedUpdateByNamePayload != nil { + *m.capturedUpdateByNamePayload = payload + } + return m +} + +type mockAPIClient struct { + updateInstanceMock edge.ApiUpdateInstanceRequest + updateInstanceByNameMock edge.ApiUpdateInstanceByNameRequest +} + +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + if m.updateInstanceMock != nil { + return m.updateInstanceMock + } + return &mockExecutable{} +} + +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + if m.updateInstanceByNameMock != nil { + return m.updateInstanceByNameMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + commonInstance.DescriptionFlag: testDescription, + commonInstance.PlanIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Description: &testDescription, + PlanId: &testPlanId, + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "no update flags", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.DescriptionFlag) + delete(flagValues, commonInstance.PlanIdFlag) + }), + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "plan id invalid", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.PlanIdFlag] = "not-a-uuid" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + want *updateRequestSpec + wantErr error + }{ + { + name: "by id", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{}, + }, + }, + want: &updateRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + Payload: edge.UpdateInstancePayload{ + Description: &testDescription, + PlanId: &testPlanId, + }, + }, + }, + { + name: "by name", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + updateInstanceByNameMock: &mockExecutable{}, + }, + }, + want: &updateRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + Payload: edge.UpdateInstancePayload{ + Description: &testDescription, + PlanId: &testPlanId, + }, + }, + }, + { + name: "no identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + { + name: "invalid identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unknown", Value: "val"} + }), + client: &mockAPIClient{}, + }, + wantErr: &cliErr.BuildRequestError{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if got != nil { + if got.Execute == nil { + t.Error("expected non-nil Execute function") + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(updateRequestSpec{}, "Execute")) + } + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "update by id success", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{}, + }, + }, + }, + { + name: "update by name success", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + updateInstanceByNameMock: &mockExecutable{}, + }, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{ + executeNotFound: true, + }, + }, + }, + }, + { + name: "update by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "update by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + updateInstanceByNameMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := run(testCtx, tt.args.model, tt.args.client) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + + tests := []struct { + name string + wantErr error + want bool + args args + }{ + { + name: "by id", + want: true, + args: args{ + model: fixtureByIdInputModel(), + }, + }, + { + name: "by name", + want: true, + args: args{ + model: fixtureByNameInputModel(), + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + }, + }, + { + name: "unknown identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier.Flag = "unknown" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} diff --git a/internal/cmd/beta/edge/kubeconfig/create/create.go b/internal/cmd/beta/edge/kubeconfig/create/create.go new file mode 100755 index 000000000..b22b7a1b3 --- /dev/null +++ b/internal/cmd/beta/edge/kubeconfig/create/create.go @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier + DisableWriting bool + Filepath *string + Overwrite bool + Expiration uint64 + SwitchContext bool +} + +// createRequestSpec captures the details of the request for testing. +type createRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string + InstanceName string + Expiration int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Kubeconfig, error) +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// KubeconfigWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type kubeconfigWaiter interface { + WaitWithContext(context.Context) (*edge.Kubeconfig, error) +} + +// A function that creates a kubeconfig waiter +type kubeconfigWaiterFactory = func(client *edge.APIClient) kubeconfigWaiter + +// waiterFactoryProvider is an interface that provides kubeconfig waiters so we can inject different impl. while testing. +type waiterFactoryProvider interface { + getKubeconfigWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (kubeconfigWaiter, error) +} + +// productionWaiterFactoryProvider is the real implementation used in production. +// It handles the concrete client type casting required by the SDK's wait handlers. +type productionWaiterFactoryProvider struct{} + +func (p *productionWaiterFactoryProvider) getKubeconfigWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (kubeconfigWaiter, error) { + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return nil, err + } + // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match. + edgeClient, ok := apiClient.(*edge.APIClient) + if !ok { + return nil, cliErr.NewBuildRequestError("failed to configure API client", nil) + } + return waiterFactory(edgeClient), nil +} + +// waiterProvider is the package-level variable used to get the waiter. +// It is initialized with the production implementation but can be overridden in tests. +var waiterProvider waiterFactoryProvider = &productionWaiterFactoryProvider{} + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates or updates a local kubeconfig file of an edge instance", + Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s", + "Creates or updates a local kubeconfig file of a STACKIT Edge Cloud (STEC) instance. If the config exists in the kubeconfig file, the information will be updated.", + "By default, the kubeconfig information of the edge instance is merged into the current kubeconfig file which is determined by Kubernetes client logic. If the kubeconfig file doesn't exist, a new one will be created.", + fmt.Sprintf("You can override this behavior by specifying a custom filepath with the --%s flag or disable writing with the --%s flag.", commonKubeconfig.FilepathFlag, commonKubeconfig.DisableWritingFlag), + fmt.Sprintf("An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is %d seconds.", commonKubeconfig.ExpirationSecondsDefault), + "Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units."), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Create or update a kubeconfig for the edge instance with %s "xxx". If the config exists in the kubeconfig file, the information will be updated.`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx"`, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Create or update a kubeconfig for the edge instance with %s "xxx" in a custom filepath.`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx" --filepath "yyy"`, commonInstance.DisplayNameFlag)), + examples.NewExample( + fmt.Sprintf(`Get a kubeconfig for the edge instance with %s "xxx" without writing it to a file and format the output as json.`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx" --disable-writing --output-format json`, commonInstance.DisplayNameFlag)), + examples.NewExample( + fmt.Sprintf(`Create a kubeconfig for the edge instance with %s "xxx". This will replace your current kubeconfig file.`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx" --overwrite`, commonInstance.InstanceIdFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Prompt for confirmation is handled in outputResult + + if model.Async { + return fmt.Errorf("async mode is not supported for kubeconfig create") + } + + // Call API via waiter (which handles both the API call and waiting) + kubeconfig, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + // Handle file operations or output to printer + return outputResult(params.Printer, model.OutputFormat, model, kubeconfig) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().Bool(commonKubeconfig.DisableWritingFlag, false, commonKubeconfig.DisableWritingUsage) + cmd.Flags().StringP(commonKubeconfig.FilepathFlag, commonKubeconfig.FilepathShorthand, "", commonKubeconfig.FilepathUsage) + cmd.Flags().StringP(commonKubeconfig.ExpirationFlag, commonKubeconfig.ExpirationShorthand, "", commonKubeconfig.ExpirationUsage) + cmd.Flags().Bool(commonKubeconfig.OverwriteFlag, false, commonKubeconfig.OverwriteUsage) + cmd.Flags().Bool(commonKubeconfig.SwitchContextFlag, false, commonKubeconfig.SwitchContextUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) + cmd.MarkFlagsMutuallyExclusive(commonKubeconfig.DisableWritingFlag, commonKubeconfig.FilepathFlag) // DisableWriting xor Filepath + cmd.MarkFlagsMutuallyExclusive(commonKubeconfig.DisableWritingFlag, commonKubeconfig.OverwriteFlag) // DisableWriting xor Overwrite +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + Filepath: flags.FlagToStringPointer(p, cmd, commonKubeconfig.FilepathFlag), + Overwrite: flags.FlagToBoolValue(p, cmd, commonKubeconfig.OverwriteFlag), + SwitchContext: flags.FlagToBoolValue(p, cmd, commonKubeconfig.SwitchContextFlag), + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Parse and validate kubeconfig expiration time + if expString := flags.FlagToStringPointer(p, cmd, commonKubeconfig.ExpirationFlag); expString != nil { + expTime, err := utils.ConvertToSeconds(*expString) + if err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + if err := commonKubeconfig.ValidateExpiration(&expTime); err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + model.Expiration = expTime + } else { + // Default expiration is 1 hour + defaultExp := uint64(commonKubeconfig.ExpirationSecondsDefault) + model.Expiration = defaultExp + } + + disableWriting := flags.FlagToBoolValue(p, cmd, commonKubeconfig.DisableWritingFlag) + model.DisableWriting = disableWriting + // Make sure to only output if the format is explicitly set + if disableWriting { + if globalFlags.OutputFormat == "" || globalFlags.OutputFormat == print.NoneOutputFormat { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.DisableWritingFlag, + Details: fmt.Sprintf("must be used with --%s", globalflags.OutputFormatFlag), + } + } + if globalFlags.OutputFormat != print.JSONOutputFormat && globalFlags.OutputFormat != print.YAMLOutputFormat { + return nil, &cliErr.FlagValidationError{ + Flag: globalflags.OutputFormatFlag, + Details: fmt.Sprintf("valid output formats for this command are: %s", fmt.Sprintf("%s, %s", print.JSONOutputFormat, print.YAMLOutputFormat)), + } + } + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Kubeconfig, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &createRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Expiration: int64(model.Expiration), // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + // Closure used to decouple the actual SDK call for easier testing + spec.Execute = func() (*edge.Kubeconfig, error) { + // Get the waiter from the provider (handles client type casting internally) + waiter, err := waiterProvider.getKubeconfigWaiter(ctx, model, apiClient) + if err != nil { + return nil, err + } + + return waiter.WaitWithContext(ctx) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (kubeconfigWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + // The KubeconfigWaitHandlers don't wait for the kubeconfig to be created, but for the instance to be ready to return a kubeconfig. + // Convert uint64 to int64 to match the API's type. + var expiration = int64(model.Expiration) // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) kubeconfigWaiter { + return wait.KubeconfigWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) kubeconfigWaiter { + return wait.KubeconfigByInstanceNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, model *inputModel, kubeconfig *edge.Kubeconfig) error { + // Ensure kubeconfig data is present + if kubeconfig == nil || kubeconfig.Kubeconfig == nil { + return fmt.Errorf("no kubeconfig returned from the API") + } + kubeconfigMap := *kubeconfig.Kubeconfig + + // Determine output format for terminal or file output + var format string + switch outputFormat { + case print.JSONOutputFormat: + // JSON if explicitly requested + format = print.JSONOutputFormat + case print.YAMLOutputFormat: + // YAML if explicitly requested + format = print.YAMLOutputFormat + default: + if model.DisableWriting { + // If not explicitly requested, use JSON as default for terminal output + format = print.JSONOutputFormat + } else { + // If not explicitly requested, use YAML as default for file output + format = print.YAMLOutputFormat + } + } + + // Marshal kubeconfig data based on the determined format + kubeconfigData, err := marshalKubeconfig(kubeconfigMap, format) + if err != nil { + return err + } + + // Handle file writing and output + if !model.DisableWriting { + // Build options for writing kubeconfig + opts := commonKubeconfig.NewWriteOptions(). + WithOverwrite(model.Overwrite). + WithSwitchContext(model.SwitchContext) + + // Add confirmation callback if not assumeYes + if !model.AssumeYes { + confirmFn := func(message string) error { + return p.PromptForConfirmation(message) + } + opts = opts.WithConfirmation(confirmFn) + } + + path, err := commonKubeconfig.WriteKubeconfig(model.Filepath, kubeconfigData, opts) + if err != nil { + return err + } + + // Inform the user about the successful write operation + p.Outputf("Wrote kubeconfig for instance %q to %q.\n", model.identifier.Value, *path) + + if model.SwitchContext { + p.Outputln("Switched context as requested.") + } + } else { + p.Outputln(kubeconfigData) + } + return nil +} + +// Marshal kubeconfig data to the specified format +func marshalKubeconfig(kubeconfigMap map[string]interface{}, format string) (string, error) { + switch format { + case print.JSONOutputFormat: + kubeconfigJSON, err := json.MarshalIndent(kubeconfigMap, "", " ") + if err != nil { + return "", fmt.Errorf("marshal kubeconfig to JSON: %w", err) + } + return string(kubeconfigJSON), nil + case print.YAMLOutputFormat: + kubeconfigYAML, err := yaml.MarshalWithOptions(kubeconfigMap, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return "", fmt.Errorf("marshal kubeconfig to YAML: %w", err) + } + return string(kubeconfigYAML), nil + default: + return "", fmt.Errorf("%w: %s", commonErr.NewNoIdentifierError(""), format) + } +} diff --git a/internal/cmd/beta/edge/kubeconfig/create/create_test.go b/internal/cmd/beta/edge/kubeconfig/create/create_test.go new file mode 100755 index 000000000..b1fbe2810 --- /dev/null +++ b/internal/cmd/beta/edge/kubeconfig/create/create_test.go @@ -0,0 +1,820 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/goccy/go-yaml" + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = "instance" + testDisplayName = "test" + testExpiration = "1h" +) + +const ( + testKubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +preferences: {} +users: +- name: user-1 + user: {} +` +) + +// Helper function to create a new instance of Kubeconfig +// +//nolint:gocritic // ptrToRefParam: Required by edge.Kubeconfig API which expects *map[string]interface{} +func testKubeconfigMap() *map[string]interface{} { + var kubeconfigMap map[string]interface{} + err := yaml.Unmarshal([]byte(testKubeconfig), &kubeconfigMap) + if err != nil { + // This should never happen in tests with valid YAML + panic(err) + } + return utils.Ptr(kubeconfigMap) +} + +// mockKubeconfigWaiter is a mock for the kubeconfigWaiter interface +type mockKubeconfigWaiter struct { + waitFails bool + waitNotFound bool + waitResp *edge.Kubeconfig +} + +func (m *mockKubeconfigWaiter) WaitWithContext(_ context.Context) (*edge.Kubeconfig, error) { + if m.waitFails { + return nil, errors.New("wait error") + } + if m.waitNotFound { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + if m.waitResp != nil { + return m.waitResp, nil + } + + // Default kubeconfig response + return &edge.Kubeconfig{ + Kubeconfig: testKubeconfigMap(), + }, nil +} + +// testWaiterFactoryProvider is a test implementation that returns mock waiters. +type testWaiterFactoryProvider struct { + waiter kubeconfigWaiter +} + +func (t *testWaiterFactoryProvider) getKubeconfigWaiter(_ context.Context, model *inputModel, _ client.APIClient) (kubeconfigWaiter, error) { + if model == nil || model.identifier == nil { + return nil, &commonErr.NoIdentifierError{} + } + + // Validate identifier like the real implementation + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag: + // Return our mock waiter directly, bypassing the client type casting issue + return t.waiter, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct{} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} + +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} + +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} + +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} + +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} + +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} + +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} + +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} + +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} + +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisableWriting: false, + Filepath: nil, + Overwrite: false, + Expiration: uint64(3600), // Default 1 hour + SwitchContext: false, + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "with expiration", + want: fixtureByIdInputModel(func(model *inputModel) { + model.Expiration = uint64(3600) + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = testExpiration + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instance id missing", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + }), + }, + }, + { + name: "instance id empty", + wantErr: "id may not be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instance id too long", + wantErr: "id is too long", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instance id too short", + wantErr: "id is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: "name is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: "name is too long", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + { + name: "disable writing and invalid output format", + wantErr: "valid output formats for this command are", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.DisableWritingFlag] = "true" + flagValues[globalflags.OutputFormatFlag] = print.PrettyOutputFormat + }), + }, + }, + { + name: "disable writing and default output format", + wantErr: "must be used with --output-format", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.DisableWritingFlag] = "true" + }), + }, + }, + { + name: "disable writing and valid output format", + want: fixtureByIdInputModel(func(model *inputModel) { + model.DisableWriting = true + model.OutputFormat = print.YAMLOutputFormat + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.DisableWritingFlag] = "true" + flagValues[globalflags.OutputFormatFlag] = print.YAMLOutputFormat + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "invalid expiration format", + wantErr: "invalid time string format", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "invalid" + }), + }, + }, + { + name: "expiration too short", + wantErr: "expiration is too small", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "1s" + }), + }, + }, + { + name: "expiration too long", + wantErr: "expiration is too large", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "13M" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + waiter kubeconfigWaiter + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "run by id success", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + { + name: "run by name success", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{waitNotFound: true}, + }, + }, + { + name: "get kubeconfig by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{waitFails: true}, + }, + }, + { + name: "get kubeconfig by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{waitFails: true}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override production waiterProvider package level variable for testing + prodWaiterProvider := waiterProvider + waiterProvider = &testWaiterFactoryProvider{waiter: tt.args.waiter} + defer func() { waiterProvider = prodWaiterProvider }() + + _, err := run(testCtx, tt.args.model, tt.args.client) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *createRequestSpec + args args + }{ + { + name: "by id", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "by name", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(createRequestSpec{}, "Execute")) + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + + tests := []struct { + name string + wantErr error + want bool + args args + }{ + { + name: "by id", + want: true, + args: args{ + model: fixtureByIdInputModel(), + }, + }, + { + name: "by name", + want: true, + args: args{ + model: fixtureByNameInputModel(), + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + }, + }, + { + name: "unknown identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier.Flag = "unknown" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + kubeconfig *edge.Kubeconfig + } + + tests := []struct { + name string + wantErr any + args args + }{ + { + name: "no kubeconfig", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + kubeconfig: nil, + }, + }, + { + name: "kubeconfig with nil kubeconfig data", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + kubeconfig: &edge.Kubeconfig{Kubeconfig: nil}, + }, + }, + { + name: "output json with disable writing", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output yaml with disable writing", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output default with disable writing", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output by name with json format and disable writing", + args: args{ + model: fixtureByNameInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output by name with yaml format and disable writing", + args: args{ + model: fixtureByNameInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output by name default with disable writing", + args: args{ + model: fixtureByNameInputModel(func(model *inputModel) { + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "file writing enabled (default behavior)", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.AssumeYes = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "file writing with overwrite enabled", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.Overwrite = true + model.AssumeYes = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "file writing with switch context enabled", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.SwitchContext = true + model.AssumeYes = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.model, tt.args.kubeconfig) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/beta/edge/kubeconfig/kubeconfig.go b/internal/cmd/beta/edge/kubeconfig/kubeconfig.go new file mode 100644 index 000000000..b44c2e1a4 --- /dev/null +++ b/internal/cmd/beta/edge/kubeconfig/kubeconfig.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package kubeconfig + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/kubeconfig/create" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "kubeconfig", + Short: "Provides functionality for edge kubeconfig.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) kubeconfig management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/plans/list/list.go b/internal/cmd/beta/edge/plans/list/list.go new file mode 100755 index 000000000..ad4c3e178 --- /dev/null +++ b/internal/cmd/beta/edge/plans/list/list.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +// User input struct for the command +const ( + limitFlag = "limit" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// listRequestSpec captures the details of the request for testing. +type listRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Limit *int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.PlanList, error) +} + +// Command constructor +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists available edge service plans", + Long: "Lists available STACKIT Edge Cloud (STEC) service plans of a project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all edge plans for a given project`, + `$ stackit beta edge-cloud plan list`), + examples.NewExample( + `Lists all edge plans for a given project and limits the output to two plans`, + fmt.Sprintf(`$ stackit beta edge-cloud plan list --%s 2`, limitFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Parse and validate user input then add it to the model + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) ([]edge.Plan, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + if resp == nil { + return nil, fmt.Errorf("list plans: empty response from API") + } + if resp.ValidPlans == nil { + return nil, fmt.Errorf("list plans: valid plans missing in response") + } + plans := *resp.ValidPlans + + // Truncate output + if spec.Limit != nil && len(plans) > int(*spec.Limit) { + plans = plans[:*spec.Limit] + } + + return plans, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*listRequestSpec, error) { + req := apiClient.ListPlansProject(ctx, model.ProjectId) + + return &listRequestSpec{ + ProjectID: model.ProjectId, + Limit: model.Limit, + Execute: req.Execute, + }, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []edge.Plan) error { + return p.OutputResult(outputFormat, plans, func() error { + // No plans found for project + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil + } + + // Display plans found for project in a table + table := tables.NewTable() + // List: only output the most important fields. Be sure to filter for any non-required fields. + table.SetHeader("ID", "NAME", "DESCRIPTION", "MAX EDGE HOSTS") + for i := range plans { + plan := plans[i] + table.AddRow( + utils.PtrString(plan.Id), + utils.PtrString(plan.Name), + utils.PtrString(plan.Description), + utils.PtrString(plan.MaxEdgeHosts)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/edge/plans/list/list_test.go b/internal/cmd/beta/edge/plans/list/list_test.go new file mode 100755 index 000000000..6b6f78275 --- /dev/null +++ b/internal/cmd/beta/edge/plans/list/list_test.go @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" +) + +// mockExecutable is a mock for the Executable interface +type mockExecutable struct { + executeFails bool + executeResp *edge.PlanList +} + +func (m *mockExecutable) Execute() (*edge.PlanList, error) { + if m.executeFails { + return nil, errors.New("API error") + } + + if m.executeResp != nil { + return m.executeResp, nil + } + return &edge.PlanList{ + ValidPlans: &[]edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + {Id: utils.Ptr("plan-2"), Name: utils.Ptr("Premium")}, + }, + }, nil +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct { + getPlansMock edge.ApiListPlansProjectRequest +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + if m.getPlansMock != nil { + return m.getPlansMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} + +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} + +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "list success", + want: fixtureInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "limit invalid value", + wantErr: "invalid syntax", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + }, + }, + { + name: "limit is zero", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + }, + }, + { + name: "limit is negative", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-0" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want []edge.Plan + args args + }{ + { + name: "list success", + want: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + {Id: utils.Ptr("plan-2"), Name: utils.Ptr("Premium")}, + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit", + want: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(1)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit greater than items", + want: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + {Id: utils.Ptr("plan-2"), Name: utils.Ptr("Premium")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(5)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with no items", + want: []edge.Plan{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{ + executeResp: &edge.PlanList{ValidPlans: &[]edge.Plan{}}, + }, + }, + }, + }, + { + name: "list API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + plans []edge.Plan + projectLabel string + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "output json", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + plans: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output yaml", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + plans: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with plans", + args: args{ + model: fixtureInputModel(), + plans: []edge.Plan{ + { + Id: utils.Ptr("plan-1"), + Name: utils.Ptr("Standard"), + Description: utils.Ptr("Standard plan description"), + }, + { + Id: utils.Ptr("plan-2"), + Name: utils.Ptr("Premium"), + Description: utils.Ptr("Premium plan description"), + }, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with no plans", + args: args{ + model: fixtureInputModel(), + plans: []edge.Plan{}, + projectLabel: "test-project", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.projectLabel, tt.args.plans) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *listRequestSpec + args args + }{ + { + name: "success", + want: &listRequestSpec{ + ProjectID: testProjectId, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{}, + }, + }, + }, + { + name: "success with limit", + want: &listRequestSpec{ + ProjectID: testProjectId, + Limit: utils.Ptr(int64(10)), + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(listRequestSpec{}, "Execute")) + }) + } +} diff --git a/internal/cmd/beta/edge/plans/plans.go b/internal/cmd/beta/edge/plans/plans.go new file mode 100644 index 000000000..d5ccb0721 --- /dev/null +++ b/internal/cmd/beta/edge/plans/plans.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package plans + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/plans/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Provides functionality for edge service plans.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) service plan management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/token/create/create.go b/internal/cmd/beta/edge/token/create/create.go new file mode 100755 index 000000000..f28e196ab --- /dev/null +++ b/internal/cmd/beta/edge/token/create/create.go @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier + Expiration uint64 +} + +// createRequestSpec captures the details of the request for testing. +type createRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string + InstanceName string + Expiration int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Token, error) +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// tokenWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type tokenWaiter interface { + WaitWithContext(context.Context) (*edge.Token, error) +} + +// A function that creates a token waiter +type tokenWaiterFactory = func(client *edge.APIClient) tokenWaiter + +// waiterFactoryProvider is an interface that provides token waiters so we can inject different impl. while testing. +type waiterFactoryProvider interface { + getTokenWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (tokenWaiter, error) +} + +// productionWaiterFactoryProvider is the real implementation used in production. +// It handles the concrete client type casting required by the SDK's wait handlers. +type productionWaiterFactoryProvider struct{} + +func (p *productionWaiterFactoryProvider) getTokenWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (tokenWaiter, error) { + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return nil, err + } + // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match. + edgeClient, ok := apiClient.(*edge.APIClient) + if !ok { + return nil, cliErr.NewBuildRequestError("failed to configure API client", nil) + } + return waiterFactory(edgeClient), nil +} + +// waiterProvider is the package-level variable used to get the waiter. +// It is initialized with the production implementation but can be overridden in tests. +var waiterProvider waiterFactoryProvider = &productionWaiterFactoryProvider{} + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a token for an edge instance", + Long: fmt.Sprintf("%s\n\n%s\n%s", + "Creates a token for a STACKIT Edge Cloud (STEC) instance.", + fmt.Sprintf("An expiration time can be set for the token. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is %d seconds.", commonKubeconfig.ExpirationSecondsDefault), + "Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units."), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Create a token for the edge instance with %s "xxx".`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud token create --%s "xxx"`, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Create a token for the edge instance with %s "xxx". The token will be valid for one day.`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud token create --%s "xxx" --expiration 1d`, commonInstance.DisplayNameFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if model.Async { + return fmt.Errorf("async mode is not supported for token create") + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + // Handle output to printer + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().StringP(commonKubeconfig.ExpirationFlag, commonKubeconfig.ExpirationShorthand, "", commonKubeconfig.ExpirationUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Parse and validate kubeconfig expiration time + if expString := flags.FlagToStringPointer(p, cmd, commonKubeconfig.ExpirationFlag); expString != nil { + expTime, err := utils.ConvertToSeconds(*expString) + if err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + if err := commonKubeconfig.ValidateExpiration(&expTime); err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + model.Expiration = expTime + } else { + // Default expiration is 1 hour + defaultExp := uint64(commonKubeconfig.ExpirationSecondsDefault) + model.Expiration = defaultExp + } + + // Make sure to only output if the format is not none + if globalFlags.OutputFormat == print.NoneOutputFormat { + return nil, &cliErr.FlagValidationError{ + Flag: globalflags.OutputFormatFlag, + Details: fmt.Sprintf("valid formats for this command are: %s", fmt.Sprintf("%s, %s, %s", print.PrettyOutputFormat, print.JSONOutputFormat, print.YAMLOutputFormat)), + } + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Token, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &createRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Expiration: int64(model.Expiration), // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + // Closure used to decouple the actual SDK call for easier testing + spec.Execute = func() (*edge.Token, error) { + // Get the waiter from the provider (handles client type casting internally) + waiter, err := waiterProvider.getTokenWaiter(ctx, model, apiClient) + if err != nil { + return nil, err + } + + return waiter.WaitWithContext(ctx) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (tokenWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + // The tokenWaitHandlers don't wait for the token to be created, but for the instance to be ready to return a token. + // Convert uint64 to int64 to match the API's type. + var expiration = int64(model.Expiration) // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) tokenWaiter { + return wait.TokenWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) tokenWaiter { + return wait.TokenByInstanceNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, token *edge.Token) error { + if token == nil || token.Token == nil { + // This is only to prevent nil pointer deref. + // As long as the API behaves as defined by it's spec, instance can not be empty (HTTP 200 with an empty body) + return fmt.Errorf("no token returned from the API") + } + tokenString := *token.Token + + return p.OutputResult(outputFormat, token, func() error { + p.Outputln(tokenString) + return nil + }) +} diff --git a/internal/cmd/beta/edge/token/create/create_test.go b/internal/cmd/beta/edge/token/create/create_test.go new file mode 100755 index 000000000..8a78d5e29 --- /dev/null +++ b/internal/cmd/beta/edge/token/create/create_test.go @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = "instance" + testDisplayName = "test" + testExpiration = "1h" +) + +// mockTokenWaiter is a mock for the tokenWaiter interface +type mockTokenWaiter struct { + waitFails bool + waitNotFound bool + waitResp *edge.Token +} + +func (m *mockTokenWaiter) WaitWithContext(_ context.Context) (*edge.Token, error) { + if m.waitFails { + return nil, errors.New("wait error") + } + if m.waitNotFound { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + if m.waitResp != nil { + return m.waitResp, nil + } + + // Default token response + tokenString := "test-token-string" + return &edge.Token{ + Token: &tokenString, + }, nil +} + +// testWaiterFactoryProvider is a test implementation that returns mock waiters. +type testWaiterFactoryProvider struct { + waiter tokenWaiter +} + +func (t *testWaiterFactoryProvider) getTokenWaiter(_ context.Context, model *inputModel, _ client.APIClient) (tokenWaiter, error) { + if model == nil || model.identifier == nil { + return nil, &commonErr.NoIdentifierError{} + } + + // Validate identifier like the real implementation + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag: + // Return our mock waiter directly, bypassing the client type casting issue + return t.waiter, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct{} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} + +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func (m *mockAPIClient) PostInstances(_ context.Context, _, _ string) edge.ApiPostInstancesRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetInstances(_ context.Context, _, _ string) edge.ApiGetInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Expiration: uint64(commonKubeconfig.ExpirationSecondsDefault), // Default 1 hour + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "with expiration", + want: fixtureByIdInputModel(func(model *inputModel) { + model.Expiration = uint64(3600) + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = testExpiration + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instance id missing", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + }), + }, + }, + { + name: "instance id empty", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instance id too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instance id too short", + wantErr: "id is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: "name is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: "name is too long", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + { + name: "invalid expiration format", + wantErr: "invalid time string format", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "invalid" + }), + }, + }, + { + name: "expiration too short", + wantErr: "expiration is too small", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "1s" + }), + }, + }, + { + name: "expiration too long", + wantErr: "expiration is too large", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "13M" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + waiter tokenWaiter + } + tests := []struct { + name string + wantErr any + wantToken bool + args args + }{ + { + name: "run by id success", + wantToken: true, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + { + name: "run by name success", + wantToken: true, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{waitNotFound: true}, + }, + }, + { + name: "get token by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{waitFails: true}, + }, + }, + { + name: "get token by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{waitFails: true}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override production waiterProvider package level variable for testing + prodWaiterProvider := waiterProvider + waiterProvider = &testWaiterFactoryProvider{waiter: tt.args.waiter} + defer func() { waiterProvider = prodWaiterProvider }() + + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if tt.wantToken && got == nil { + t.Fatal("expected non-nil token") + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *createRequestSpec + args args + }{ + { + name: "by id", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "by name", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(createRequestSpec{}, "Execute")) + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + tests := []struct { + name string + want bool + wantErr error + args args + }{ + { + name: "by id", + want: true, + args: args{model: fixtureByIdInputModel()}, + }, + { + name: "by name", + want: true, + args: args{model: fixtureByNameInputModel()}, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + })}, + }, + { + name: "unknown identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{model: fixtureInputModel(false, func(model *inputModel) { + model.identifier.Flag = "unknown" + })}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + token *edge.Token + } + tests := []struct { + name string + wantErr any + args args + }{ + { + name: "default output format", + args: args{ + model: fixtureByIdInputModel(), + token: &edge.Token{ + Token: func() *string { s := "test-token"; return &s }(), + }, + }, + }, + { + name: "JSON output format", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + token: &edge.Token{ + Token: func() *string { s := "test-token"; return &s }(), + }, + }, + }, + { + name: "YAML output format", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + token: &edge.Token{ + Token: func() *string { s := "test-token"; return &s }(), + }, + }, + }, + { + name: "nil token", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + token: nil, + }, + }, + { + name: "nil token string", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + token: &edge.Token{Token: nil}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + err := outputResult(p, tt.args.model.OutputFormat, tt.args.token) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/beta/edge/token/token.go b/internal/cmd/beta/edge/token/token.go new file mode 100644 index 000000000..8fd725a72 --- /dev/null +++ b/internal/cmd/beta/edge/token/token.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package token + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/token/create" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Provides functionality for edge service token.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) token management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/config/profile/import/template/profile.json b/internal/cmd/config/profile/import/template/profile.json index fd14150d7..ed2702e7e 100644 --- a/internal/cmd/config/profile/import/template/profile.json +++ b/internal/cmd/config/profile/import/template/profile.json @@ -3,6 +3,7 @@ "async": false, "authorization_custom_endpoint": "", "dns_custom_endpoint": "", + "edge_custom_endpoint": "", "iaas_custom_endpoint": "", "identity_provider_custom_client_id": "", "identity_provider_custom_well_known_configuration": "", @@ -31,4 +32,4 @@ "sqlserverflex_custom_endpoint": "", "token_custom_endpoint": "", "verbosity": "info" -} \ No newline at end of file +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index cf81c4906..64ee916f4 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -26,6 +26,7 @@ const ( authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" + edgeCustomEndpointFlag = "edge-custom-endpoint" loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" @@ -143,6 +144,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(observabilityCustomEndpointFlag, "", "Observability 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(edgeCustomEndpointFlag, "", "Edge 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") @@ -182,6 +184,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.EdgeCustomEndpointKey, cmd.Flags().Lookup(edgeCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.LoadBalancerCustomEndpointKey, cmd.Flags().Lookup(loadBalancerCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.LogMeCustomEndpointKey, cmd.Flags().Lookup(logMeCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index a3ce21e73..c20aa131c 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -30,6 +30,7 @@ const ( authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" + edgeCustomEndpointFlag = "edge-custom-endpoint" loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" @@ -70,6 +71,7 @@ type inputModel struct { AuthorizationCustomEndpoint bool DNSCustomEndpoint bool + EdgeCustomEndpoint bool LoadBalancerCustomEndpoint bool LogMeCustomEndpoint bool MariaDBCustomEndpoint bool @@ -154,6 +156,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.DNSCustomEndpoint { viper.Set(config.DNSCustomEndpointKey, "") } + if model.EdgeCustomEndpoint { + viper.Set(config.EdgeCustomEndpointKey, "") + } if model.LoadBalancerCustomEndpoint { viper.Set(config.LoadBalancerCustomEndpointKey, "") } @@ -250,6 +255,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(observabilityCustomEndpointFlag, false, "Observability 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(edgeCustomEndpointFlag, false, "Edge 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") @@ -290,6 +296,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { AuthorizationCustomEndpoint: flags.FlagToBoolValue(p, cmd, authorizationCustomEndpointFlag), DNSCustomEndpoint: flags.FlagToBoolValue(p, cmd, dnsCustomEndpointFlag), + EdgeCustomEndpoint: flags.FlagToBoolValue(p, cmd, edgeCustomEndpointFlag), LoadBalancerCustomEndpoint: flags.FlagToBoolValue(p, cmd, loadBalancerCustomEndpointFlag), LogMeCustomEndpoint: flags.FlagToBoolValue(p, cmd, logMeCustomEndpointFlag), MariaDBCustomEndpoint: flags.FlagToBoolValue(p, cmd, mariaDBCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 4ebb6cde7..37ee8f7d2 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -25,6 +25,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool authorizationCustomEndpointFlag: true, dnsCustomEndpointFlag: true, + edgeCustomEndpointFlag: true, loadBalancerCustomEndpointFlag: true, logMeCustomEndpointFlag: true, mariaDBCustomEndpointFlag: true, @@ -67,6 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { AuthorizationCustomEndpoint: true, DNSCustomEndpoint: true, + EdgeCustomEndpoint: true, LoadBalancerCustomEndpoint: true, LogMeCustomEndpoint: true, MariaDBCustomEndpoint: true, @@ -125,6 +127,7 @@ func TestParseInput(t *testing.T) { model.AuthorizationCustomEndpoint = false model.DNSCustomEndpoint = false + model.EdgeCustomEndpoint = false model.LoadBalancerCustomEndpoint = false model.LogMeCustomEndpoint = false model.MariaDBCustomEndpoint = false @@ -218,6 +221,16 @@ func TestParseInput(t *testing.T) { model.DNSCustomEndpoint = false }), }, + { + description: "edge custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[edgeCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.EdgeCustomEndpoint = false + }), + }, { description: "secrets manager custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 03170f650..a60bbd17b 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -25,6 +25,7 @@ const ( AuthorizationCustomEndpointKey = "authorization_custom_endpoint" AlbCustomEndpoint = "alb_custom _endpoint" DNSCustomEndpointKey = "dns_custom_endpoint" + EdgeCustomEndpointKey = "edge_custom_endpoint" LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint" LogMeCustomEndpointKey = "logme_custom_endpoint" MariaDBCustomEndpointKey = "mariadb_custom_endpoint" @@ -85,6 +86,7 @@ var ConfigKeys = []string{ AllowedUrlDomainKey, DNSCustomEndpointKey, + EdgeCustomEndpointKey, LoadBalancerCustomEndpointKey, LogMeCustomEndpointKey, MariaDBCustomEndpointKey, @@ -179,6 +181,7 @@ func setConfigDefaults() { viper.SetDefault(IdentityProviderCustomClientIdKey, "") viper.SetDefault(AllowedUrlDomainKey, AllowedUrlDomainDefault) viper.SetDefault(DNSCustomEndpointKey, "") + viper.SetDefault(EdgeCustomEndpointKey, "") viper.SetDefault(ObservabilityCustomEndpointKey, "") viper.SetDefault(AuthorizationCustomEndpointKey, "") viper.SetDefault(MongoDBFlexCustomEndpointKey, "") diff --git a/internal/pkg/config/template/test_profile.json b/internal/pkg/config/template/test_profile.json index fd14150d7..ed2702e7e 100644 --- a/internal/pkg/config/template/test_profile.json +++ b/internal/pkg/config/template/test_profile.json @@ -3,6 +3,7 @@ "async": false, "authorization_custom_endpoint": "", "dns_custom_endpoint": "", + "edge_custom_endpoint": "", "iaas_custom_endpoint": "", "identity_provider_custom_client_id": "", "identity_provider_custom_well_known_configuration": "", @@ -31,4 +32,4 @@ "sqlserverflex_custom_endpoint": "", "token_custom_endpoint": "", "verbosity": "info" -} \ No newline at end of file +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 814929497..d690259ea 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -1,10 +1,13 @@ package errors import ( + "encoding/json" + sysErrors "errors" "fmt" "strings" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" ) const ( @@ -548,3 +551,136 @@ type OneOfFlagsIsMissing struct { func (e *OneOfFlagsIsMissing) Error() string { return fmt.Sprintf(ONE_OF_THE_FLAGS_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET, e.MissingFlags, e.SetFlag) } + +// ___FORMATTING_ERRORS_________________________________________________________ + +// InvalidFormatError indicates that an unsupported format was provided. +type InvalidFormatError struct { + Format string // The invalid format that was provided +} + +func (e *InvalidFormatError) Error() string { + if e.Format != "" { + return fmt.Sprintf("unsupported format provided: %s", e.Format) + } + return "unsupported format provided" +} + +// NewInvalidFormatError creates a new InvalidFormatError with the provided format. +func NewInvalidFormatError(format string) *InvalidFormatError { + return &InvalidFormatError{ + Format: format, + } +} + +// ___BUILD_REQUEST_ERRORS______________________________________________________ +// BuildRequestError indicates that a request could not be built. +type BuildRequestError struct { + Reason string // Optional: specific reason why the request failed to build + Err error // Optional: underlying error +} + +func (e *BuildRequestError) Error() string { + if e.Reason != "" && e.Err != nil { + return fmt.Sprintf("could not build request (%s): %v", e.Reason, e.Err) + } + if e.Reason != "" { + return fmt.Sprintf("could not build request: %s", e.Reason) + } + if e.Err != nil { + return fmt.Sprintf("could not build request: %v", e.Err) + } + return "could not build request" +} + +func (e *BuildRequestError) Unwrap() error { + return e.Err +} + +// NewBuildRequestError creates a new BuildRequestError with optional reason and underlying error. +func NewBuildRequestError(reason string, err error) *BuildRequestError { + return &BuildRequestError{ + Reason: reason, + Err: err, + } +} + +// ___REQUESTS_ERRORS___________________________________________________________ +// RequestFailedError indicates that an API request failed. +// If the provided error is an OpenAPI error, the status code and message from the error body will be included in the error message. +type RequestFailedError struct { + Err error // Optional: underlying error +} + +func (e *RequestFailedError) Error() string { + var msg = "request failed" + + if e.Err != nil { + var oApiErr *oapierror.GenericOpenAPIError + if sysErrors.As(e.Err, &oApiErr) { + // Extract status code from OpenAPI error header if it exists + if oApiErr.StatusCode > 0 { + msg += fmt.Sprintf(" (%d)", oApiErr.StatusCode) + } + + // Try to extract message from OpenAPI error body + if bodyMsg := extractOpenApiMessageFromBody(oApiErr.Body); bodyMsg != "" { + msg += fmt.Sprintf(": %s", bodyMsg) + } else if trimmedBody := strings.TrimSpace(string(oApiErr.Body)); trimmedBody != "" { + msg += fmt.Sprintf(": %s", trimmedBody) + } else { + // Otherwise use the Go error + msg += fmt.Sprintf(": %v", e.Err) + } + } else { + // If this can't be cased into a OpenApi error use the Go error + msg += fmt.Sprintf(": %v", e.Err) + } + } + + return msg +} + +func (e *RequestFailedError) Unwrap() error { + return e.Err +} + +// NewRequestFailedError creates a new RequestFailedError with optional details. +func NewRequestFailedError(err error) *RequestFailedError { + return &RequestFailedError{ + Err: err, + } +} + +// ___HELPERS___________________________________________________________________ +// extractOpenApiMessageFromBody attempts to parse a JSON body and extract the "message" +// field. It returns an empty string if parsing fails or if no message is found. +func extractOpenApiMessageFromBody(body []byte) string { + trimmedBody := strings.TrimSpace(string(body)) + // Return early if empty. + if trimmedBody == "" { + return "" + } + + // Try to unmarshal as a structured error first + var errorBody struct { + Message string `json:"message"` + } + if err := json.Unmarshal(body, &errorBody); err == nil && errorBody.Message != "" { + if msg := strings.TrimSpace(errorBody.Message); msg != "" { + return msg + } + } + + // If that fails, try to unmarshal as a plain string + var plainBody string + if err := json.Unmarshal(body, &plainBody); err == nil && plainBody != "" { + if msg := strings.TrimSpace(plainBody); msg != "" { + return msg + } + return "" + } + + // All parsing attempts failed or yielded no message + return "" +} diff --git a/internal/pkg/errors/errors_test.go b/internal/pkg/errors/errors_test.go index d2942e87f..8a1c3d117 100644 --- a/internal/pkg/errors/errors_test.go +++ b/internal/pkg/errors/errors_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" ) var cmd *cobra.Command @@ -13,6 +14,13 @@ var service *cobra.Command var resource *cobra.Command var operation *cobra.Command +var ( + testErrorMessage = "test error message" + errStringErrTest = errors.New(testErrorMessage) + errOpenApi404 = &oapierror.GenericOpenAPIError{StatusCode: 404, Body: []byte(`{"message":"not found"}`)} + errOpenApi500 = &oapierror.GenericOpenAPIError{StatusCode: 500, Body: []byte(`invalid-json`)} +) + func setupCmd() { cmd = &cobra.Command{ Use: "stackit", @@ -686,3 +694,238 @@ func TestAppendUsageTip(t *testing.T) { }) } } + +func TestInvalidFormatError(t *testing.T) { + type args struct { + format string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + format: "", + }, + want: "unsupported format provided", + }, + { + name: "with format", + args: args{ + format: "yaml", + }, + want: "unsupported format provided: yaml", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&InvalidFormatError{Format: tt.args.format}).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildRequestError(t *testing.T) { + type args struct { + reason string + err error + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + reason: "", + err: nil, + }, + want: "could not build request", + }, + { + name: "reason only", + args: args{ + reason: testErrorMessage, + err: nil, + }, + want: fmt.Sprintf("could not build request: %s", testErrorMessage), + }, + { + name: "error only", + args: args{ + reason: "", + err: errStringErrTest, + }, + want: fmt.Sprintf("could not build request: %s", testErrorMessage), + }, + { + name: "reason and error", + args: args{ + reason: testErrorMessage, + err: errStringErrTest, + }, + want: fmt.Sprintf("could not build request (%s): %s", testErrorMessage, testErrorMessage), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&BuildRequestError{Reason: tt.args.reason, Err: tt.args.err}).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestRequestFailedError(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args args + want string + }{ + { + name: "nil underlying", + args: args{ + err: nil, + }, + want: "request failed", + }, + { + name: "non-openapi error", + args: args{ + err: errStringErrTest, + }, + want: fmt.Sprintf("request failed: %s", testErrorMessage), + }, + { + name: "openapi error with message", + args: args{ + err: errOpenApi404, + }, + want: "request failed (404): not found", + }, + { + name: "openapi error without message", + args: args{ + err: errOpenApi500, + }, + want: "request failed (500): invalid-json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&RequestFailedError{Err: tt.args.err}).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractMessageFromBody(t *testing.T) { + type args struct { + body []byte + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty body", + args: args{ + body: []byte(""), + }, + want: "", + }, + { + name: "invalid json", + args: args{ + body: []byte("not-json"), + }, + want: "", + }, + { + name: "missing message field", + args: args{ + body: []byte(`{"error":"oops"}`), + }, + want: "", + }, + { + name: "with message field", + args: args{ + body: []byte(`{"message":"the reason"}`), + }, + want: "the reason", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractOpenApiMessageFromBody(tt.args.body) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestConstructorsReturnExpected(t *testing.T) { + buildRequestError := NewBuildRequestError(testErrorMessage, errStringErrTest) + + tests := []struct { + name string + got any + want any + }{ + { + name: "InvalidFormat format", + got: NewInvalidFormatError("fmt").Format, + want: "fmt", + }, + { + name: "BuildRequestError error", + got: buildRequestError.Err, + want: errStringErrTest, + }, + { + name: "BuildRequestError reason", + got: buildRequestError.Reason, + want: testErrorMessage, + }, + { + name: "RequestFailed error", + got: NewRequestFailedError(errStringErrTest).Err, + want: errStringErrTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wantErr, wantIsErr := tt.want.(error) + gotErr, gotIsErr := tt.got.(error) + if wantIsErr { + if !gotIsErr { + t.Fatalf("expected error but got %T", tt.got) + } + if !errors.Is(gotErr, wantErr) { + t.Errorf("got error %v, want %v", gotErr, wantErr) + } + return + } + + if tt.got != tt.want { + t.Errorf("got %v, want %v", tt.got, tt.want) + } + }) + } +} diff --git a/internal/pkg/services/edge/client/client.go b/internal/pkg/services/edge/client/client.go new file mode 100644 index 000000000..566e4cc05 --- /dev/null +++ b/internal/pkg/services/edge/client/client.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package client + +import ( + "context" + + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +// APIClient is an interface that consolidates all client functionality to allow for mocking of the API client during testing. +type APIClient interface { + PostInstances(ctx context.Context, projectId, region string) edge.ApiPostInstancesRequest + DeleteInstance(ctx context.Context, projectId, region, instanceId string) edge.ApiDeleteInstanceRequest + DeleteInstanceByName(ctx context.Context, projectId, region, instanceName string) edge.ApiDeleteInstanceByNameRequest + GetInstance(ctx context.Context, projectId, region, instanceId string) edge.ApiGetInstanceRequest + GetInstanceByName(ctx context.Context, projectId, region, instanceName string) edge.ApiGetInstanceByNameRequest + GetInstances(ctx context.Context, projectId, region string) edge.ApiGetInstancesRequest + UpdateInstance(ctx context.Context, projectId, region, instanceId string) edge.ApiUpdateInstanceRequest + UpdateInstanceByName(ctx context.Context, projectId, region, instanceName string) edge.ApiUpdateInstanceByNameRequest + GetKubeconfigByInstanceId(ctx context.Context, projectId, region, instanceId string) edge.ApiGetKubeconfigByInstanceIdRequest + GetKubeconfigByInstanceName(ctx context.Context, projectId, region, instanceName string) edge.ApiGetKubeconfigByInstanceNameRequest + GetTokenByInstanceId(ctx context.Context, projectId, region, instanceId string) edge.ApiGetTokenByInstanceIdRequest + GetTokenByInstanceName(ctx context.Context, projectId, region, instanceName string) edge.ApiGetTokenByInstanceNameRequest + ListPlansProject(ctx context.Context, projectId string) edge.ApiListPlansProjectRequest +} + +// ConfigureClient configures and returns a new API client for the Edge service. +func ConfigureClient(p *print.Printer, cliVersion string) (APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.EdgeCustomEndpointKey), false, edge.NewAPIClient) +} diff --git a/internal/pkg/services/edge/common/error/error.go b/internal/pkg/services/edge/common/error/error.go new file mode 100755 index 000000000..2fd1433c3 --- /dev/null +++ b/internal/pkg/services/edge/common/error/error.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +// Package error provides custom error types for STACKIT Edge Cloud operations. +// +// This package defines structured error types that provide better error handling +// and type checking compared to simple string errors. Each error type can carry +// additional context and implements the standard error interface. +package error + +import ( + "fmt" +) + +// NoIdentifierError indicates that no identifier was provided when one was required. +type NoIdentifierError struct { + Operation string // Optional: which operation failed +} + +func (e *NoIdentifierError) Error() string { + if e.Operation != "" { + return fmt.Sprintf("no identifier provided for %s", e.Operation) + } + return "no identifier provided" +} + +// InvalidIdentifierError indicates that an unsupported identifier was provided. +type InvalidIdentifierError struct { + Identifier string // The invalid identifier that was provided +} + +func (e *InvalidIdentifierError) Error() string { + if e.Identifier != "" { + return fmt.Sprintf("unsupported identifier provided: %s", e.Identifier) + } + return "unsupported identifier provided" +} + +// InstanceExistsError indicates that a specific instance already exists. +type InstanceExistsError struct { + DisplayName string // Optional: the display name that was searched for +} + +func (e *InstanceExistsError) Error() string { + if e.DisplayName != "" { + return fmt.Sprintf("instance already exists: %s", e.DisplayName) + } + return "instance already exists" +} + +// NoInstanceError indicates that no instance was provided in a context where one was expected. +type NoInstanceError struct { + Context string // Optional: context where no instance was found (e.g., "in response", "in project") +} + +func (e *NoInstanceError) Error() string { + if e.Context != "" { + return fmt.Sprintf("no instance provided %s", e.Context) + } + return "no instance provided" +} + +// NewNoIdentifierError creates a new NoIdentifierError with optional context. +func NewNoIdentifierError(operation string) *NoIdentifierError { + return &NoIdentifierError{Operation: operation} +} + +// NewInvalidIdentifierError creates a new InvalidIdentifierError with the provided identifier. +func NewInvalidIdentifierError(identifier string) *InvalidIdentifierError { + return &InvalidIdentifierError{ + Identifier: identifier, + } +} + +// NewInstanceExistsError creates a new InstanceExistsError with optional instance details. +func NewInstanceExistsError(displayName string) *InstanceExistsError { + return &InstanceExistsError{ + DisplayName: displayName, + } +} + +// NewNoInstanceError creates a new NoInstanceError with optional context. +func NewNoInstanceError(context string) *NoInstanceError { + return &NoInstanceError{Context: context} +} diff --git a/internal/pkg/services/edge/common/error/error_test.go b/internal/pkg/services/edge/common/error/error_test.go new file mode 100755 index 000000000..1268d898a --- /dev/null +++ b/internal/pkg/services/edge/common/error/error_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +// Unit tests for package error +package error + +import ( + "testing" + + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +func TestNoIdentifierError(t *testing.T) { + type args struct { + operation string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + operation: "", + }, + want: "no identifier provided", + }, + { + name: "with operation", + args: args{ + operation: "create", + }, + want: "no identifier provided for create", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&NoIdentifierError{Operation: tt.args.operation}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestInvalidIdentifierError(t *testing.T) { + type args struct { + id string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + id: "", + }, + want: "unsupported identifier provided", + }, + { + name: "with identifier", + args: args{ + id: "x-123", + }, + want: "unsupported identifier provided: x-123", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&InvalidIdentifierError{Identifier: tt.args.id}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestInstanceExistsError(t *testing.T) { + type args struct { + name string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{name: ""}, + want: "instance already exists"}, + { + name: "with display name", + args: args{name: "my-inst"}, + want: "instance already exists: my-inst", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&InstanceExistsError{DisplayName: tt.args.name}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestNoInstanceError(t *testing.T) { + type args struct { + ctx string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + ctx: "", + }, + want: "no instance provided", + }, + { + name: "with context", + args: args{ + ctx: "in project", + }, + want: "no instance provided in project", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&NoInstanceError{Context: tt.args.ctx}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestConstructorsReturnExpected(t *testing.T) { + tests := []struct { + name string + got any + want any + }{ + { + name: "NoIdentifier operation", + got: NewNoIdentifierError("op").Operation, + want: "op", + }, + { + name: "InvalidIdentifier identifier", + got: NewInvalidIdentifierError("id").Identifier, + want: "id", + }, + { + name: "InstanceExists displayName", + got: NewInstanceExistsError("name").DisplayName, + want: "name", + }, + { + name: "NoInstance context", + got: NewNoInstanceError("ctx").Context, + want: "ctx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wantErr, wantIsErr := tt.want.(error) + gotErr, gotIsErr := tt.got.(error) + if wantIsErr { + if !gotIsErr { + t.Fatalf("expected error but got %T", tt.got) + } + testUtils.AssertError(t, gotErr, wantErr) + return + } + + testUtils.AssertValue(t, tt.got, tt.want) + }) + } +} diff --git a/internal/pkg/services/edge/common/instance/instance.go b/internal/pkg/services/edge/common/instance/instance.go new file mode 100644 index 000000000..6dc35c672 --- /dev/null +++ b/internal/pkg/services/edge/common/instance/instance.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package instance + +import ( + "fmt" + "regexp" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliUtils "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// Validation constants taken from OpenApi spec. +const ( + displayNameMinimumChars = 4 + displayNameMaximumChars = 8 + displayNameRegex = `^[a-z]([-a-z0-9]*[a-z0-9])?$` + descriptionMaxLength = 256 + instanceIdMaxLength = 16 + instanceIdMinLength = displayNameMinimumChars + 1 // Instance ID is generated by extending the display name. +) + +// User input flags for instance commands +const ( + DisplayNameFlag = "name" // > displayNameMinimumChars <= displayNameMaximumChars characters + regex displayNameRegex + DescriptionFlag = "description" // <= descriptionMaxLength characters + PlanIdFlag = "plan-id" // UUID + InstanceIdFlag = "id" // instance id (unique per project) +) + +// Flag usage texts +const ( + DisplayNameUsage = "The displayed name to distinguish multiple instances." + DescriptionUsage = "A user chosen description to distinguish multiple instances." + PlanIdUsage = "Service Plan configures the size of the Instance." + InstanceIdUsage = "The project-unique identifier of this instance." +) + +// Flag shorthands +const ( + DisplayNameShorthand = "n" + DescriptionShorthand = "d" + InstanceIdShorthand = "i" +) + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls, which are currently impl. as separate endpoints. +// To make the code more flexible, we use a struct to hold the request model. +type RequestModel struct { + Value any +} + +func ValidateDisplayName(displayName *string) error { + if displayName == nil { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s may not be empty", DisplayNameFlag), + } + } + + if len(*displayName) > displayNameMaximumChars { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DisplayNameFlag, displayNameMaximumChars), + } + } + if len(*displayName) < displayNameMinimumChars { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", DisplayNameFlag, displayNameMinimumChars), + } + } + displayNameRegex := regexp.MustCompile(displayNameRegex) + if !displayNameRegex.MatchString(*displayName) { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + } + } + return nil +} + +func ValidatePlanId(planId *string) error { + if planId == nil { + return &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s may not be empty", PlanIdFlag), + } + } + + err := cliUtils.ValidateUUID(*planId) + if err != nil { + return &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: %v", PlanIdFlag, err), + } + } + return nil +} + +func ValidateDescription(description string) error { + if len(description) > descriptionMaxLength { + return &cliErr.FlagValidationError{ + Flag: DescriptionFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DescriptionFlag, descriptionMaxLength), + } + } + + return nil +} + +func ValidateInstanceId(instanceId *string) error { + if instanceId == nil { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + } + } + + if *instanceId == "" { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + } + } + if len(*instanceId) < instanceIdMinLength { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", InstanceIdFlag, instanceIdMinLength), + } + } + if len(*instanceId) > instanceIdMaxLength { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", InstanceIdFlag, instanceIdMaxLength), + } + } + + return nil +} diff --git a/internal/pkg/services/edge/common/instance/instance_test.go b/internal/pkg/services/edge/common/instance/instance_test.go new file mode 100755 index 000000000..70a7dd11d --- /dev/null +++ b/internal/pkg/services/edge/common/instance/instance_test.go @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package instance + +import ( + "fmt" + "strings" + "testing" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func TestValidateDisplayName(t *testing.T) { + type args struct { + displayName *string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "valid minimum length", + args: &args{displayName: utils.Ptr("test")}, + }, + { + name: "valid maximum length", + args: &args{displayName: utils.Ptr("testname")}, + }, + { + name: "valid with hyphens", + args: &args{displayName: utils.Ptr("test-app")}, + }, + { + name: "valid with numbers", + args: &args{displayName: utils.Ptr("test123")}, + }, + { + name: "valid starting with letter", + args: &args{displayName: utils.Ptr("a-test")}, + }, + + // Error cases - nil pointer + { + name: "nil display name", + args: &args{displayName: nil}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s may not be empty", DisplayNameFlag), + }, + }, + + // Error cases - length validation + { + name: "too short", + args: &args{displayName: utils.Ptr("abc")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", DisplayNameFlag, displayNameMinimumChars), + }, + }, + { + name: "too long", + args: &args{displayName: utils.Ptr("verylongname")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DisplayNameFlag, displayNameMaximumChars), + }, + }, + + // Error cases - regex validation + { + name: "starts with number", + args: &args{displayName: utils.Ptr("1test")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "starts with hyphen", + args: &args{displayName: utils.Ptr("-test")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "ends with hyphen", + args: &args{displayName: utils.Ptr("test-")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "contains uppercase", + args: &args{displayName: utils.Ptr("Test")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "contains special characters", + args: &args{displayName: utils.Ptr("test@")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateDisplayName(tt.args.displayName) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestValidatePlanId(t *testing.T) { + type args struct { + planId *string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "valid UUID v4", + args: &args{planId: utils.Ptr("550e8400-e29b-41d4-a716-446655440000")}, + }, + { + name: "valid UUID lowercase", + args: &args{planId: utils.Ptr("6ba7b810-9dad-11d1-80b4-00c04fd430c8")}, + }, + { + name: "valid UUID uppercase", + args: &args{planId: utils.Ptr("6BA7B810-9DAD-11D1-80B4-00C04FD430C8")}, + }, + { + name: "valid UUID without hyphens", + args: &args{planId: utils.Ptr("550e8400e29b41d4a716446655440000")}, + }, + + // Error cases - nil pointer + { + name: "nil plan id", + args: &args{planId: nil}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s may not be empty", PlanIdFlag), + }, + }, + + // Error cases - invalid UUID format + { + name: "invalid UUID - too short", + args: &args{planId: utils.Ptr("550e8400-e29b-41d4-a716")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse 550e8400-e29b-41d4-a716 as UUID: invalid UUID length: 23", PlanIdFlag), + }, + }, + { + name: "invalid UUID - invalid characters", + args: &args{planId: utils.Ptr("550e8400-e29b-41d4-a716-44665544000g")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse 550e8400-e29b-41d4-a716-44665544000g as UUID: invalid UUID format", PlanIdFlag), + }, + }, + { + name: "not a UUID", + args: &args{planId: utils.Ptr("not-a-uuid")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse not-a-uuid as UUID: invalid UUID length: 10", PlanIdFlag), + }, + }, + { + name: "empty string", + args: &args{planId: utils.Ptr("")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse as UUID: invalid UUID length: 0", PlanIdFlag), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePlanId(tt.args.planId) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestValidateDescription(t *testing.T) { + type args struct { + description string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "empty description", + args: &args{description: ""}, + }, + { + name: "short description", + args: &args{description: "A short description"}, + }, + { + name: "description at maximum length", + args: &args{description: strings.Repeat("a", descriptionMaxLength)}, + }, + { + name: "description with special characters", + args: &args{description: "Description with special chars: !@#$%^&*()"}, + }, + { + name: "description with unicode", + args: &args{description: "Description with unicode: 你好世界 🌍"}, + }, + + // Error cases + { + name: "description too long", + args: &args{description: strings.Repeat("a", descriptionMaxLength+1)}, + want: &cliErr.FlagValidationError{ + Flag: DescriptionFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DescriptionFlag, descriptionMaxLength), + }, + }, + { + name: "description way too long", + args: &args{description: strings.Repeat("a", descriptionMaxLength+100)}, + want: &cliErr.FlagValidationError{ + Flag: DescriptionFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DescriptionFlag, descriptionMaxLength), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateDescription(tt.args.description) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestValidateInstanceId(t *testing.T) { + type args struct { + instanceId *string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "valid instance id at minimum length", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMinLength))}, + }, + { + name: "valid instance id at maximum length", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMaxLength))}, + }, + { + name: "valid instance id with mixed characters", + args: &args{instanceId: utils.Ptr("test-instance")}, + }, + + // Error cases - nil pointer + { + name: "nil instance id", + args: &args{instanceId: nil}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + }, + }, + + // Error cases - empty string + { + name: "empty string", + args: &args{instanceId: utils.Ptr("")}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + }, + }, + + // Error cases - length validation + { + name: "too short", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMinLength-1))}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", InstanceIdFlag, instanceIdMinLength), + }, + }, + { + name: "way too short", + args: &args{instanceId: utils.Ptr("a")}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", InstanceIdFlag, instanceIdMinLength), + }, + }, + { + name: "too long", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMaxLength+1))}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", InstanceIdFlag, instanceIdMaxLength), + }, + }, + { + name: "way too long", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMaxLength+10))}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", InstanceIdFlag, instanceIdMaxLength), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateInstanceId(tt.args.instanceId) + testUtils.AssertError(t, err, tt.want) + }) + } +} diff --git a/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go b/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go new file mode 100755 index 000000000..ef0918c8b --- /dev/null +++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package kubeconfig + +import ( + "fmt" + "maps" + "math" + "os" + "path/filepath" + + "k8s.io/client-go/tools/clientcmd" +) + +// Validation constants taken from OpenApi spec. +const ( + expirationSecondsMax = 15552000 // 60 * 60 * 24 * 180 seconds = 180 days + expirationSecondsMin = 600 // 60 * 10 seconds = 10 minutes +) + +// Defaults taken from OpenApi spec. +const ( + ExpirationSecondsDefault = 3600 // 60 * 60 seconds = 1 hour +) + +// User input flags for kubeconfig commands +const ( + ExpirationFlag = "expiration" + DisableWritingFlag = "disable-writing" + FilepathFlag = "filepath" + OverwriteFlag = "overwrite" + SwitchContextFlag = "switch-context" +) + +// Flag usage texts +const ( + ExpirationUsage = "Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h." + FilepathUsage = "Path to the kubeconfig file. A default is chosen by Kubernetes if not set." + DisableWritingUsage = "Disable writing the kubeconfig to a file." + OverwriteUsage = "Force overwrite the kubeconfig file if it exists." + SwitchContextUsage = "Switch to the context in the kubeconfig file to the new context." +) + +// Flag shorthands +const ( + ExpirationShorthand = "e" + DisableWritingShorthand = "" + FilepathShorthand = "f" + OverwriteShorthand = "" + SwitchContextShorthand = "" +) + +func ValidateExpiration(expiration *uint64) error { + if expiration != nil { + // We're using utils.ConvertToSeconds to convert the user input string to seconds, which is using + // math.MaxUint64 internally, if no special limits are set. However: the OpenApi v3 Spec + // only allows integers (int64). So we could end up in a overflow IF expirationSecondsMax + // ever is changed beyond the maximum value of int64. This check makes sure this won't happen. + maxExpiration := uint64(math.Min(expirationSecondsMax, math.MaxInt64)) + if *expiration > maxExpiration { + return fmt.Errorf("%s is too large (maximum is %d seconds)", ExpirationFlag, maxExpiration) + } + // If expiration is ever changed to int64 this check makes sure we never end up with negative expiration times. + minExpiration := uint64(math.Max(expirationSecondsMin, 0)) + if *expiration < minExpiration { + return fmt.Errorf("%s is too small (minimum is %d seconds)", ExpirationFlag, minExpiration) + } + } + return nil +} + +// EmptyKubeconfigError is returned when the kubeconfig content is empty. +type EmptyKubeconfigError struct{} + +// Error returns the error message. +func (e *EmptyKubeconfigError) Error() string { + return "no data for kubeconfig" +} + +// LoadKubeconfigError is returned when loading the kubeconfig fails. +type LoadKubeconfigError struct { + Err error +} + +// Error returns the error message. +func (e *LoadKubeconfigError) Error() string { + return fmt.Sprintf("load kubeconfig: %v", e.Err) +} + +// Unwrap returns the underlying error. +func (e *LoadKubeconfigError) Unwrap() error { + return e.Err +} + +// WriteKubeconfigError is returned when writing the kubeconfig fails. +type WriteKubeconfigError struct { + Err error +} + +// Error returns the error message. +func (e *WriteKubeconfigError) Error() string { + return fmt.Sprintf("write kubeconfig: %v", e.Err) +} + +// Unwrap returns the underlying error. +func (e *WriteKubeconfigError) Unwrap() error { + return e.Err +} + +// InvalidKubeconfigPathError is returned when an invalid kubeconfig path is provided. +type InvalidKubeconfigPathError struct { + Path string +} + +// Error returns the error message. +func (e *InvalidKubeconfigPathError) Error() string { + return fmt.Sprintf("invalid path: %s", e.Path) +} + +// mergeKubeconfig merges new kubeconfig data into a kubeconfig file. +// +// If the destination file does not exist, it will be created. If the file exists, +// the new data (clusters, contexts, and users) is merged into the existing +// configuration, overwriting entries with the same name and replacing the +// current-context if defined in the new data. +// +// The function takes the following parameters: +// - configPath: The path to the destination file. The file and the directory tree +// for the file will be created if it does not exist. +// - data: The new kubeconfig content to merge. Merge is performed based on standard +// kubeconfig structure. +// - switchContext: If true, the function will switch to the new context in the +// kubeconfig file after merging. +// +// It returns a nil error on success. On failure, it returns an error indicating +// if the provided data was empty, malformed, or if there were issues reading from +// or writing to the filesystem. +func mergeKubeconfig(filePath *string, data string, switchContext bool) error { + if filePath == nil { + return fmt.Errorf("no kubeconfig file provided to be merged") + } + path := *filePath + + // Check if the new kubeconfig data is empty + if data == "" { + return &EmptyKubeconfigError{} + } + + // Load and validate the data into a kubeconfig object + newConfig, err := clientcmd.Load([]byte(data)) + if err != nil { + return &LoadKubeconfigError{Err: err} + } + + // If the destination kubeconfig does not exist, create a new one. IsNotExist will ignore other errors. + // Other errors are handled separately by the following clientcmd.LoadFromFile clientcmd.LoadFromFile + if _, err := os.Stat(path); os.IsNotExist(err) { + return writeKubeconfig(&path, data) + } + + // If the file exists load and validate the existing kubeconfig into a config object + existingConfig, err := clientcmd.LoadFromFile(path) + if err != nil { + return &LoadKubeconfigError{Err: err} + } + + // Merge the new kubeconfig data into the existing config object + maps.Copy(existingConfig.AuthInfos, newConfig.AuthInfos) + maps.Copy(existingConfig.Clusters, newConfig.Clusters) + maps.Copy(existingConfig.Contexts, newConfig.Contexts) + + // If no CurrentContext is set or switchContext is true, set the CurrentContext to the CurrentContext of the new kubeconfig + if newConfig.CurrentContext != "" && (switchContext || existingConfig.CurrentContext == "") { + existingConfig.CurrentContext = newConfig.CurrentContext + } + + // Save the merged config to the file, creating missing directories as needed. + if err := clientcmd.WriteToFile(*existingConfig, path); err != nil { + return &WriteKubeconfigError{Err: err} + } + + return nil +} + +// writeKubeconfig writes kubeconfig data to a file, overwriting it if it exists. +// +// The function takes the following parameters: +// - configPath: The path to the destination file. The file and the directory tree +// for the file will be created if it does not exist. +// - data: The new kubeconfig content to write to the file. +// +// It returns a nil error on success. On failure, it returns an error indicating +// if the provided data was empty, malformed, or if there were issues reading from +// or writing to the filesystem. +func writeKubeconfig(filePath *string, data string) error { + if filePath == nil { + return fmt.Errorf("no kubeconfig file provided to be written") + } + path := *filePath + + // Check if the new kubeconfig data is empty + if data == "" { + return &EmptyKubeconfigError{} + } + + // Load and validate the data into a kubeconfig object + config, err := clientcmd.Load([]byte(data)) + if err != nil { + return &LoadKubeconfigError{Err: err} + } + + // Save the merged config to the file, creating missing directories as needed. + if err := clientcmd.WriteToFile(*config, path); err != nil { + return &WriteKubeconfigError{Err: err} + } + + return nil +} + +// getDefaultKubeconfigPath returns the default location for the kubeconfig file, +// following standard Kubernetes loading rules. +// +// It returns a string containing the absolute path to the default kubeconfig file. +func getDefaultKubeconfigPath() string { + return clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename() +} + +// Returns the absolute path to the kubeconfig file. +// If a file path is provided, it is validated and, if valid, returned as an absolute path. +// If nil is provided the default kubeconfig path is loaded and returned as an absolute path. +func getKubeconfigPath(filePath *string) (string, error) { + if filePath == nil { + return getDefaultKubeconfigPath(), nil + } + + if isValidFilePath(filePath) { + return filepath.Abs(*filePath) + } + return "", &InvalidKubeconfigPathError{Path: *filePath} +} + +// Basic filesystem path validation. Returns true if the provided string is a path. Returns false otherwise. +func isValidFilePath(filePath *string) bool { + if filePath == nil || *filePath == "" { + return false + } + + // Clean the path and check if it's valid + cleaned := filepath.Clean(*filePath) + if cleaned == "." || cleaned == string(filepath.Separator) { + return false + } + + // Try to get absolute path (this will fail for invalid paths) + _, err := filepath.Abs(*filePath) + // If no error, the path is valid (return true). Otherwise, it's invalid (return false). + return err == nil +} + +// Basic filesystem file existence check. Returns true if the file exists. Returns false otherwise. +func isExistingFile(filePath *string) bool { + // Check if the kubeconfig file exists + _, errStat := os.Stat(*filePath) + return !os.IsNotExist(errStat) +} + +// ConfirmationCallback is a function that prompts for confirmation with the given message +// and returns true if confirmed, false otherwise +type ConfirmationCallback func(message string) error + +// WriteOptions contains options for writing kubeconfig files +type WriteOptions struct { + Overwrite bool + SwitchContext bool + ConfirmFn ConfirmationCallback +} + +// WithOverwrite sets whether to overwrite existing files instead of merging +func (w WriteOptions) WithOverwrite(overwrite bool) WriteOptions { + w.Overwrite = overwrite + return w +} + +// WithSwitchContext sets whether to switch to the new context after writing +func (w WriteOptions) WithSwitchContext(switchContext bool) WriteOptions { + w.SwitchContext = switchContext + return w +} + +// WithConfirmation sets the confirmation callback function +func (w WriteOptions) WithConfirmation(fn ConfirmationCallback) WriteOptions { + w.ConfirmFn = fn + return w +} + +// NewWriteOptions creates a new WriteOptions with default values +func NewWriteOptions() WriteOptions { + return WriteOptions{ + Overwrite: false, + SwitchContext: false, + ConfirmFn: nil, + } +} + +// WriteKubeconfig writes the provided kubeconfig data to a file on the filesystem. +// By default, if the file already exists, it will be merged with the provided data. +// This behavior can be controlled using the provided options. +// +// The function takes the following parameters: +// - filePath: The path to the destination file. The file and the directory tree for the +// file will be created if it does not exist. If nil, the default kubeconfig path is used. +// - kubeconfig: The kubeconfig content to write. +// - options: Options for controlling the write behavior. +// +// It returns the file path actually used to write to on success. +func WriteKubeconfig(filePath *string, kubeconfig string, options WriteOptions) (*string, error) { + // Check if the provided filePath is valid or use the default kubeconfig path no filePath is provided + path, err := getKubeconfigPath(filePath) + if err != nil { + return nil, err + } + + if isExistingFile(&path) { + // If the file exists + if !options.Overwrite { + // If overwrite was not requested the default it to merge + if options.ConfirmFn != nil { + // If confirmation callback is provided, prompt the user for confirmation + prompt := fmt.Sprintf("Update your kubeconfig %q?", path) + err := options.ConfirmFn(prompt) + if err != nil { + // If the user doesn't confirm do not proceed with the merge + return nil, err + } + } + err := mergeKubeconfig(&path, kubeconfig, options.SwitchContext) + if err != nil { + return nil, err + } + return &path, err + } + // If overwrite was requested overwrite the existing file + if options.ConfirmFn != nil { + // If confirmation callback is provided, prompt the user for confirmation + prompt := fmt.Sprintf("Replace your kubeconfig %q?", path) + err := options.ConfirmFn(prompt) + if err != nil { + // If the user doesn't confirm do not proceed with the overwrite + return nil, err + } + // Fallthrough + } + } + // If the file doesn't exist or in case the user confirmed the overwrite (fallthrough) write the file + err = writeKubeconfig(&path, kubeconfig) + if err != nil { + return nil, err + } + return &path, err +} diff --git a/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go new file mode 100755 index 000000000..59bbba0b0 --- /dev/null +++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go @@ -0,0 +1,744 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package kubeconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "k8s.io/client-go/tools/clientcmd" +) + +var ( + testErrorMessage = "test error message" + errStringErrTest = errors.New(testErrorMessage) +) + +const ( + kubeconfig_1_yaml = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +preferences: {} +users: +- name: user-1 + user: {} +` + kubeconfig_2_yaml = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-2.com + name: cluster-2 +contexts: +- context: + cluster: cluster-2 + user: user-2 + name: context-2 +current-context: context-2 +kind: Config +users: +- name: user-2 + user: {} +` + overwriteKubeconfigTarget = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +users: +- name: user-1 + user: + token: old-token +` + overwriteKubeconfigSource = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1-new.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +users: +- name: user-1 + user: + token: new-token +` +) + +func TestValidateExpiration(t *testing.T) { + type args struct { + expiration *uint64 + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "nil expiration", + args: &args{ + expiration: nil, + }, + }, + { + name: "valid expiration - minimum value", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMin)), + }, + }, + { + name: "valid expiration - maximum value", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMax)), + }, + }, + { + name: "valid expiration - default value", + args: &args{ + expiration: utils.Ptr(uint64(ExpirationSecondsDefault)), + }, + }, + { + name: "valid expiration - middle value", + args: &args{ + expiration: utils.Ptr(uint64(86400)), // 1 day + }, + }, + + // Error cases - below minimum + { + name: "expiration too small - below minimum", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMin - 1)), + }, + want: fmt.Errorf("%s is too small (minimum is %d seconds)", ExpirationFlag, expirationSecondsMin), + }, + { + name: "expiration too small - zero", + args: &args{ + expiration: utils.Ptr(uint64(0)), + }, + want: fmt.Errorf("%s is too small (minimum is %d seconds)", ExpirationFlag, expirationSecondsMin), + }, + + // Error cases - above maximum + { + name: "expiration too large - above maximum", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMax + 1)), + }, + want: fmt.Errorf("%s is too large (maximum is %d seconds)", ExpirationFlag, expirationSecondsMax), + }, + { + name: "expiration too large - way above maximum", + args: &args{ + expiration: utils.Ptr(uint64(9999999999999999999)), + }, + want: fmt.Errorf("%s is too large (maximum is %d seconds)", ExpirationFlag, expirationSecondsMax), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateExpiration(tt.args.expiration) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestErrors(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args *args + wantErr error + }{ + // EmptyKubeconfigError + { + name: "EmptyKubeconfigError", + args: &args{ + err: &EmptyKubeconfigError{}, + }, + wantErr: &EmptyKubeconfigError{}, + }, + + // LoadKubeconfigError + { + name: "LoadKubeconfigError", + args: &args{ + err: &LoadKubeconfigError{Err: errStringErrTest}, + }, + wantErr: errStringErrTest, + }, + + // WriteKubeconfigError + { + name: "WriteKubeconfigError", + args: &args{ + err: &WriteKubeconfigError{Err: errStringErrTest}, + }, + wantErr: errStringErrTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testUtils.AssertError(t, tt.args.err, tt.wantErr) + }) + } +} + +// Already have comprehensive tests for WriteKubeconfig + +func TestWriteOptions(t *testing.T) { + confirmFn := func(_ string) error { return nil } + + type args struct { + modify func(WriteOptions) WriteOptions + check func(*testing.T, WriteOptions) + } + tests := []struct { + name string + args *args + }{ + // Default options + { + name: "NewWriteOptions creates default options", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o }, + check: func(t *testing.T, opts WriteOptions) { + if opts.Overwrite { + t.Error("expected Overwrite to be false by default") + } + if opts.SwitchContext { + t.Error("expected SwitchContext to be false by default") + } + if opts.ConfirmFn != nil { + t.Error("expected ConfirmFn to be nil by default") + } + }, + }, + }, + + // Individual option tests + { + name: "WithOverwrite sets overwrite flag", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o.WithOverwrite(true) }, + check: func(t *testing.T, opts WriteOptions) { + if !opts.Overwrite { + t.Error("expected Overwrite to be true") + } + }, + }, + }, + { + name: "WithSwitchContext sets switch context flag", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o.WithSwitchContext(true) }, + check: func(t *testing.T, opts WriteOptions) { + if !opts.SwitchContext { + t.Error("expected SwitchContext to be true") + } + }, + }, + }, + { + name: "WithConfirmation sets confirmation callback", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o.WithConfirmation(confirmFn) }, + check: func(t *testing.T, opts WriteOptions) { + if opts.ConfirmFn == nil { + t.Error("expected ConfirmFn to be set") + } + }, + }, + }, + + // Chained options + { + name: "options are chainable", + args: &args{ + modify: func(o WriteOptions) WriteOptions { + return o.WithOverwrite(true). + WithSwitchContext(true). + WithConfirmation(confirmFn) + }, + check: func(t *testing.T, opts WriteOptions) { + if !opts.Overwrite { + t.Error("expected Overwrite to be true") + } + if !opts.SwitchContext { + t.Error("expected SwitchContext to be true") + } + if opts.ConfirmFn == nil { + t.Error("expected ConfirmFn to be set") + } + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := tt.args.modify(NewWriteOptions()) + tt.args.check(t, opts) + }) + } +} + +func TestGetDefaultKubeconfigPath(t *testing.T) { + type args struct { + kubeconfigEnv *string // nil means unset + } + tests := []struct { + name string + args *args + want string + }{ + // KUBECONFIG not set + { + name: "returns a non-empty path when KUBECONFIG is not set", + args: &args{kubeconfigEnv: nil}, + want: "", + }, + + // Single path + { + name: "returns path from KUBECONFIG if set", + args: &args{kubeconfigEnv: utils.Ptr("/test/kubeconfig_1_yaml")}, + want: "/test/kubeconfig_1_yaml", + }, + + // Multiple paths + { + name: "returns first path from KUBECONFIG if multiple are set", + args: &args{kubeconfigEnv: utils.Ptr("/test/kubeconfig_1_yaml" + string(os.PathListSeparator) + "/test/kubeconfig_2_yaml")}, + want: "/test/kubeconfig_1_yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env and restore after test + oldKubeconfig := os.Getenv("KUBECONFIG") + defer func() { + if err := os.Setenv("KUBECONFIG", oldKubeconfig); err != nil { + t.Logf("failed to restore KUBECONFIG: %v", err) + } + }() + + // Setup test environment + if tt.args.kubeconfigEnv == nil { + if err := os.Unsetenv("KUBECONFIG"); err != nil { + t.Fatalf("failed to unset KUBECONFIG: %v", err) + } + } else { + if err := os.Setenv("KUBECONFIG", *tt.args.kubeconfigEnv); err != nil { + t.Fatalf("failed to set KUBECONFIG: %v", err) + } + } + + // Run test + got := getDefaultKubeconfigPath() + + // If want is empty only make sure the returned path is not empty + // In that case we don't care about what path is default, only that one is. + want := filepath.Clean(tt.want) + if want == filepath.Clean("") { + if filepath.Clean(got) != "" { + return + } + } + + // Verify results + testUtils.AssertValue(t, filepath.Clean(got), want) + }) + } +} + +func TestGetKubeconfigPath(t *testing.T) { + type args struct { + path *string + checkPath func(t *testing.T, path string) + } + tests := []struct { + name string + args *args + wantErr error + }{ + { + name: "uses default path when nil provided", + args: &args{ + path: nil, + checkPath: func(t *testing.T, path string) { + if path == "" { + t.Error("expected non-empty path") + } + }, + }, + }, + { + name: "validates and returns absolute path when valid path provided", + args: &args{ + path: utils.Ptr("/tmp/kubeconfig"), + checkPath: func(t *testing.T, path string) { + if !filepath.IsAbs(path) { + t.Error("expected absolute path") + } + }, + }, + }, + { + name: "returns error for invalid path", + args: &args{ + path: utils.Ptr("."), + }, + wantErr: &InvalidKubeconfigPathError{Path: "."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := getKubeconfigPath(tt.args.path) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if tt.args.checkPath != nil { + tt.args.checkPath(t, path) + } + }) + } +} + +func TestIsValidFilePath(t *testing.T) { + type args struct { + path *string + } + tests := []struct { + name string + args *args + + want bool + }{ + { + name: "valid path", + args: &args{ + path: utils.Ptr("/test/kubeconfig"), + }, + want: true, + }, + { + name: "nil path", + args: &args{ + path: nil, + }, + want: false, + }, + { + name: "empty path", + args: &args{ + path: utils.Ptr(""), + }, + want: false, + }, + { + name: "single dot", + args: &args{ + path: utils.Ptr("."), + }, + want: false, + }, + { + name: "single slash", + args: &args{ + path: utils.Ptr("/"), + }, + want: false, + }, + { + name: "relative path with parent directory", + args: &args{ + path: utils.Ptr("../kubeconfig"), + }, + want: true, + }, + { + name: "path with spaces", + args: &args{ + path: utils.Ptr("/test/kube config"), + }, + want: true, + }, + { + name: "complex but valid path", + args: &args{ + path: utils.Ptr("/test/kube-config.d/cluster1/config"), + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidFilePath(tt.args.path); got != tt.want { + t.Errorf("isValidFilePath() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWriteKubeconfig(t *testing.T) { + testPath := filepath.Join(t.TempDir(), "config") + defaultTempFile := filepath.Join(t.TempDir(), "default-kubeconfig") + + type args struct { + path *string + content string + options WriteOptions + setupEnv func() + checkFile func(t *testing.T, path string) + } + tests := []struct { + name string + args *args + wantPath *string + wantErr any + }{ + { + name: "writes new file with default options", + args: &args{ + path: &testPath, + content: kubeconfig_1_yaml, + options: NewWriteOptions(), + checkFile: func(t *testing.T, path string) { + if !isExistingFile(&path) { + t.Error("file was not created") + } + }, + }, + wantPath: &testPath, + }, + { + name: "handles invalid file path", + args: &args{ + path: utils.Ptr("."), + content: kubeconfig_1_yaml, + options: NewWriteOptions(), + }, + wantErr: &InvalidKubeconfigPathError{Path: "."}, + }, + { + name: "handles empty kubeconfig", + args: &args{ + path: &testPath, + content: "", + options: NewWriteOptions(), + }, + wantErr: &EmptyKubeconfigError{}, + }, + { + name: "uses default path when nil provided", + args: &args{ + path: nil, + content: kubeconfig_1_yaml, + options: NewWriteOptions(), + setupEnv: func() { + t.Setenv("KUBECONFIG", defaultTempFile) + }, + }, + wantPath: &defaultTempFile, + }, + { + name: "overwrites existing file when option is set", + args: &args{ + path: &testPath, + content: kubeconfig_2_yaml, + options: NewWriteOptions().WithOverwrite(true), + setupEnv: func() { + // Pre-write first file + if _, err := WriteKubeconfig(&testPath, kubeconfig_1_yaml, NewWriteOptions()); err != nil { + t.Fatalf("failed to setup test: %v", err) + } + }, + checkFile: func(t *testing.T, path string) { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read kubeconfig: %v", err) + } + if !strings.Contains(string(content), "server-2.com") { + t.Error("file was not overwritten") + } + }, + }, + wantPath: &testPath, + }, + { + name: "respects user confirmation - confirmed", + args: &args{ + path: &testPath, + content: kubeconfig_1_yaml, + options: NewWriteOptions().WithConfirmation(func(_ string) error { + return nil + }), + }, + wantPath: &testPath, + }, + { + name: "respects user confirmation - denied", + args: &args{ + path: &testPath, + content: kubeconfig_1_yaml, + options: NewWriteOptions().WithConfirmation(func(_ string) error { + return errStringErrTest + }), + }, + wantErr: errStringErrTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.setupEnv != nil { + tt.args.setupEnv() + } + + got, gotErr := WriteKubeconfig(tt.args.path, tt.args.content, tt.args.options) + if !testUtils.AssertError(t, gotErr, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.wantPath) + + if tt.args.checkFile != nil { + tt.args.checkFile(t, *got) + } + }) + } +} + +func TestMergeKubeconfig(t *testing.T) { + type args struct { + path *string + content string + switchCtx bool + setupEnv func() + } + tests := []struct { + name string + args args + verify func(t *testing.T, path string) + wantErr error + }{ + { + name: "merges configs with conflicting names", + args: args{ + path: utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), + content: overwriteKubeconfigSource, + switchCtx: true, + setupEnv: func() { + // Pre-write first file + if _, err := WriteKubeconfig(utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), overwriteKubeconfigTarget, NewWriteOptions()); err != nil { + t.Fatalf("failed to setup test: %v", err) + } + }, + }, + verify: func(t *testing.T, path string) { + config, err := clientcmd.LoadFromFile(path) + if err != nil { + t.Fatalf("failed to load merged config: %v", err) + } + + cluster := config.Clusters["cluster-1"] + if cluster.Server != "https://server-1-new.com" { + t.Errorf("expected server to be 'https://server-1-new.com', got '%s'", cluster.Server) + } + + user := config.AuthInfos["user-1"] + if user.Token != "new-token" { + t.Errorf("expected token to be 'new-token', got '%s'", user.Token) + } + }, + }, + { + name: "handles nil file path", + args: args{ + path: nil, + content: kubeconfig_1_yaml, + switchCtx: false, + }, + wantErr: fmt.Errorf("no kubeconfig file provided to be merged"), + }, + { + name: "handles invalid config", + args: args{ + path: utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), + content: "invalid yaml", + switchCtx: false, + }, + wantErr: &LoadKubeconfigError{}, + }, + { + name: "handles empty config", + args: args{ + path: utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), + content: "", + switchCtx: false, + }, + wantErr: &EmptyKubeconfigError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.setupEnv != nil { + tt.args.setupEnv() + } + + err := mergeKubeconfig(tt.args.path, tt.args.content, tt.args.switchCtx) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.verify != nil { + if tt.args.path == nil { + t.Fatalf("expected path to be set") + } + tt.verify(t, *tt.args.path) + } + }) + } +} diff --git a/internal/pkg/services/edge/common/validation/input.go b/internal/pkg/services/edge/common/validation/input.go new file mode 100644 index 000000000..33a2d0c46 --- /dev/null +++ b/internal/pkg/services/edge/common/validation/input.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package validation + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" +) + +// Struct to model the instance identifier provided by the user (either instance-id or display-name) +type Identifier struct { + Flag string + Value string +} + +// GetValidatedInstanceIdentifier gets and validates the instance identifier provided by the user through the command-line flags. +// It checks for either an instance ID or a display name and validates the provided value. +// +// p is the printer used for logging. +// cmd is the cobra command that holds the flags. +// +// Returns an Identifier struct containing the flag and its value if a valid identifier is provided, otherwise returns an error. +// Indirect unit tests of GetValidatedInstanceIdentifier are done within the respective CLI packages. +func GetValidatedInstanceIdentifier(p *print.Printer, cmd *cobra.Command) (*Identifier, error) { + switch { + case cmd.Flags().Changed(commonInstance.InstanceIdFlag): + instanceIdValue := flags.FlagToStringPointer(p, cmd, commonInstance.InstanceIdFlag) + if err := commonInstance.ValidateInstanceId(instanceIdValue); err != nil { + return nil, err + } + return &Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: *instanceIdValue, + }, nil + case cmd.Flags().Changed(commonInstance.DisplayNameFlag): + displayNameValue := flags.FlagToStringPointer(p, cmd, commonInstance.DisplayNameFlag) + if err := commonInstance.ValidateDisplayName(displayNameValue); err != nil { + return nil, err + } + return &Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: *displayNameValue, + }, nil + default: + return nil, commonErr.NewNoIdentifierError("") + } +} diff --git a/internal/pkg/services/edge/common/validation/input_test.go b/internal/pkg/services/edge/common/validation/input_test.go new file mode 100755 index 000000000..0ccb2d3b2 --- /dev/null +++ b/internal/pkg/services/edge/common/validation/input_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package validation + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +func TestGetValidatedInstanceIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*cobra.Command) + want *Identifier + wantErr any + }{ + { + name: "instance id success", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.InstanceIdFlag, "", "") + _ = cmd.Flags().Set(commonInstance.InstanceIdFlag, "edgesvc01") + }, + want: &Identifier{Flag: commonInstance.InstanceIdFlag, Value: "edgesvc01"}, + }, + { + name: "display name success", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.DisplayNameFlag, "", "") + _ = cmd.Flags().Set(commonInstance.DisplayNameFlag, "edge01") + }, + want: &Identifier{Flag: commonInstance.DisplayNameFlag, Value: "edge01"}, + }, + { + name: "instance id validation error", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.InstanceIdFlag, "", "") + _ = cmd.Flags().Set(commonInstance.InstanceIdFlag, "id") + }, + wantErr: "too short", + }, + { + name: "display name validation error", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.DisplayNameFlag, "", "") + _ = cmd.Flags().Set(commonInstance.DisplayNameFlag, "x") + }, + wantErr: "too short", + }, + { + name: "no identifier", + setup: func(_ *cobra.Command) {}, + wantErr: &commonErr.NoIdentifierError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + printer := print.NewPrinter() + cmd := &cobra.Command{Use: "test"} + tt.setup(cmd) + + got, err := GetValidatedInstanceIdentifier(printer, cmd) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if tt.want != nil { + testUtils.AssertValue(t, got, tt.want) + } + }) + } +} diff --git a/internal/pkg/testutils/assert.go b/internal/pkg/testutils/assert.go new file mode 100755 index 000000000..42c280a71 --- /dev/null +++ b/internal/pkg/testutils/assert.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +// Package test provides utilities for validating CLI command test results with +// explicit helpers for error expectations and value comparisons. By splitting +// error and value handling the package keeps assertions simple and removes the +// need for dynamic type checks in every test case. +// +// Example usage: +// +// // Expect a specific error type +// if !test.AssertError(t, run(), &cliErr.FlagValidationError{}) { +// return +// } +// +// // Expect any error +// if !test.AssertError(t, run(), true) { +// return +// } +// +// // Expect error message substring +// if !test.AssertError(t, run(), "not found") { +// return +// } +// +// // Compare complex structs with private fields +// test.AssertValue(t, got, want, test.WithAllowUnexported(MyStruct{})) + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +// AssertError verifies that an observed error satisfies the expected condition. +// +// Returns: +// - bool: True if the test should continue to value checks (i.e., no error occurred). +// +// Behavior: +// 1. If err is nil: +// - If want is nil or false: Success. +// - If want is anything else: Fails test (Expected error but got nil). +// 2. If err is non-nil: +// - If want is nil or false: Fails test (Unexpected error). +// - If want is true: Success (Any error accepted). +// - If want is string: Asserts err.Error() contains the string. +// - If want is error: Asserts errors.Is(err, want) or type match. +func AssertError(t testing.TB, got error, want any) bool { + t.Helper() + + // Case 1: No error occurred + if got == nil { + if want == nil || want == false { + return true + } + t.Errorf("got nil error, want %v", want) + return false + } + + // Case 2: Error occurred + if want == nil || want == false { + t.Errorf("got unexpected error: %v", got) + return false + } + + if want == true { + return false // Error expected and received, stop test + } + + // Handle string error type expectation + if wantStr, ok := want.(string); ok { + if !strings.Contains(got.Error(), wantStr) { + t.Errorf("got error %q, want substring %q", got, wantStr) + } + return false + } + + // Handle specific error type expectation + if wantErr, ok := want.(error); ok { + if checkErrorMatch(got, wantErr) { + return false + } + t.Errorf("got error %v, want %v", got, wantErr) + return false + } + + t.Errorf("invalid want type %T for AssertError", want) + return false +} + +func checkErrorMatch(got, want error) bool { + if errors.Is(got, want) { + return true + } + + // Fallback to type check using errors.As to handle wrapped errors + if want != nil { + typ := reflect.TypeOf(want) + // errors.As requires a pointer to the target type. + // reflect.New(typ) returns *T where T is the type of want. + target := reflect.New(typ).Interface() + if errors.As(got, target) { + return true + } + } + + return false +} + +// DiffFunc compares two values and returns a diff string. An empty string means +// equality. +type DiffFunc func(got, want any) string + +// ValueComparisonOption configures how HandleValueResult applies cmp options or +// diffing strategies. +type ValueComparisonOption func(*valueComparisonConfig) + +type valueComparisonConfig struct { + diffFunc DiffFunc + cmpOptions []cmp.Option +} + +func (config *valueComparisonConfig) getDiffFunc() DiffFunc { + if config.diffFunc != nil { + return config.diffFunc + } + return func(got, want any) string { + return cmp.Diff(got, want, config.cmpOptions...) + } +} + +// WithCmpOptions accumulates cmp.Options used during value comparison. +func WithAssertionCmpOptions(opts ...cmp.Option) ValueComparisonOption { + return func(config *valueComparisonConfig) { + config.cmpOptions = append(config.cmpOptions, opts...) + } +} + +// WithAllowUnexported enables comparison of unexported fields for the provided +// struct types. +func WithAllowUnexported(types ...any) ValueComparisonOption { + return WithAssertionCmpOptions(cmp.AllowUnexported(types...)) +} + +// WithDiffFunc sets a custom diffing function. Providing this option overrides +// the default cmp-based diff logic. +func WithDiffFunc(diffFunc DiffFunc) ValueComparisonOption { + return func(config *valueComparisonConfig) { + config.diffFunc = diffFunc + } +} + +// WithIgnoreFields ignores the specified fields on the provided type during comparison. +// It uses cmpopts.IgnoreFields to ensure type-safe filtering. +func WithIgnoreFields(typ any, names ...string) ValueComparisonOption { + return WithAssertionCmpOptions(cmpopts.IgnoreFields(typ, names...)) +} + +// AssertValue compares two values with cmp.Diff while allowing callers to +// tweak the diff strategy via ValueComparisonOption. A non-empty diff is +// reported as an error containing the diff output. +func AssertValue[T any](t testing.TB, got, want T, opts ...ValueComparisonOption) { + t.Helper() + // Configure comparison options + config := &valueComparisonConfig{} + for _, opt := range opts { + opt(config) + } + // Perform comparison and report diff + diff := config.getDiffFunc()(got, want) + if diff != "" { + t.Errorf("values do not match: %s", diff) + } +} diff --git a/internal/pkg/testutils/assert_test.go b/internal/pkg/testutils/assert_test.go new file mode 100755 index 000000000..ae683a54b --- /dev/null +++ b/internal/pkg/testutils/assert_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp/cmpopts" +) + +type customError struct{ msg string } + +func (e *customError) Error() string { return e.msg } + +type anotherError struct{ code int } + +func (e *anotherError) Error() string { return fmt.Sprintf("code=%d", e.code) } + +type mockTB struct { + testing.TB + failed bool + msg string +} + +func (m *mockTB) Helper() {} +func (m *mockTB) Errorf(format string, args ...any) { + m.failed = true + m.msg = fmt.Sprintf(format, args...) +} + +func TestAssertError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("sentinel") + + tests := map[string]struct { + got error // The input provided as got to AssertError() + want any // The input provided as want to AssertError() + wantErr bool // Whether this comparison is expected to fail + }{ + "exact match": { + got: &customError{msg: "boom"}, + want: &customError{}, + wantErr: false, + }, + "error string message match": { + got: errors.New("same message"), + want: "same message", + wantErr: false, + }, + "error string mismatch": { + got: errors.New("different"), + want: "same message", + wantErr: true, + }, + "sentinel via errors.Is": { + got: fmt.Errorf("wrap: %w", sentinel), + want: sentinel, + wantErr: false, + }, + "any error (true)": { + got: errors.New("any"), + want: true, + wantErr: false, + }, + "nil expectation (nil)": { + got: nil, + want: nil, + wantErr: false, + }, + "nil expectation (false)": { + got: nil, + want: false, + wantErr: false, + }, + "nil error input with error expectation": { + got: nil, + want: true, + wantErr: true, + }, + "unexpected error (nil want)": { + got: errors.New("unexpected"), + want: nil, + wantErr: true, + }, + "type match without message": { + got: &customError{msg: "alpha"}, + want: &customError{msg: "beta"}, + wantErr: false, + }, + "type mismatch": { + got: &customError{msg: "alpha"}, + want: &anotherError{}, + wantErr: true, + }, + "no error when none expected": { + got: nil, + want: false, + wantErr: false, + }, + "error but want false": { + got: errors.New("boom"), + want: false, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + mock := &mockTB{} + result := AssertError(mock, tt.got, tt.want) + + // if the test failed but we didn't expect it to fail + if mock.failed != tt.wantErr { + t.Fatalf("AssertError() failed = %v, wantErr %v (msg: %s)", mock.failed, tt.wantErr, mock.msg) + } + // if we expected an error the result of AssertError() should be false (this is what AssertError() does in case of error) + if tt.wantErr && result != false { + t.Fatalf("AssertError() returned = %v, want %v", result, tt.wantErr) + } + }) + } +} + +func TestCheckErrorMatch(t *testing.T) { + t.Parallel() + + underlying := &customError{msg: "root"} + wrapped := fmt.Errorf("wrap: %w", underlying) + if !checkErrorMatch(wrapped, &customError{}) { + t.Fatalf("expected wrapped customError to match via errors.As") + } + + notMatch := errors.New("other") + if checkErrorMatch(notMatch, &anotherError{}) { + t.Fatalf("expected mismatch for unrelated error types") + } +} + +func TestAssertValue(t *testing.T) { + t.Parallel() + + type payload struct { + Visible string + hidden int + } + + customDiff := func(got, want any) string { + if reflect.DeepEqual(got, want) { + return "" + } + return "custom diff" + } + + tests := []struct { + name string + got any // The input provided as got to AssertValue() + want any // The input provided as want to AssertValue() + wantErr bool // Whether this comparison is expected to fail + opts []ValueComparisonOption + }{ + { + name: "allow unexported success", + got: payload{Visible: "ok", hidden: 1}, + want: payload{Visible: "ok", hidden: 1}, + opts: []ValueComparisonOption{WithAllowUnexported(payload{})}, + }, + { + name: "allow unexported mismatch", + got: payload{Visible: "oops", hidden: 1}, + want: payload{Visible: "ok", hidden: 1}, + opts: []ValueComparisonOption{WithAllowUnexported(payload{})}, + wantErr: true, + }, + { + name: "cmp options sort", + got: []string{"b", "a", "c"}, + want: []string{"a", "b", "c"}, + opts: []ValueComparisonOption{WithAssertionCmpOptions(cmpopts.SortSlices(func(a, b string) bool { return a < b }))}, + }, + { + name: "custom diff mismatch", + got: 1, + want: 2, + opts: []ValueComparisonOption{WithDiffFunc(customDiff)}, + wantErr: true, + }, + { + name: "default diff success", + got: 42, + want: 42, + }, + { + name: "default diff mismatch", + got: 1, + want: 2, + wantErr: true, + }, + { + name: "diff func overrides cmp options", + got: []string{"b"}, + want: []string{"a"}, + opts: []ValueComparisonOption{ + WithAssertionCmpOptions(cmpopts.SortSlices(func(a, b string) bool { return a < b })), + WithDiffFunc(func(_, _ any) string { return "" }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mock := &mockTB{} + AssertValue(mock, tt.got, tt.want, tt.opts...) + + // if the test failed but we didn't expect it to fail + if mock.failed != tt.wantErr { + t.Fatalf("AssertValue failed = %v, want %v (msg: %s)", mock.failed, tt.wantErr, mock.msg) + } + }) + } +} diff --git a/internal/pkg/testutils/parse_input.go b/internal/pkg/testutils/parse_input.go new file mode 100755 index 000000000..a0deafbc7 --- /dev/null +++ b/internal/pkg/testutils/parse_input.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +// ParseInputTestCase aggregates all required elements to exercise a CLI parseInput +// function. It centralizes the common flag setup, validation, and result +// assertions used throughout the edge command test suites. +type ParseInputTestCase[T any] struct { + Name string + // Args simulates positional arguments passed to the command. + Args []string + // Flags sets simple single-value flags. + Flags map[string]string + // RepeatFlags sets flags that can be specified multiple times (e.g. slice flags). + RepeatFlags map[string][]string + WantModel T + WantErr any + CmdFactory func(*types.CmdParams) *cobra.Command + // ParseInputFunc is the function under test. It must accept the printer, command, and args. + ParseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error) +} + +// ParseInputCaseOption allows configuring the test execution behavior. +type ParseInputCaseOption func(*parseInputCaseConfig) + +type parseInputCaseConfig struct { + cmpOpts []ValueComparisonOption +} + +// WithParseInputCmpOptions sets custom comparison options for AssertValue. +func WithParseInputCmpOptions(opts ...ValueComparisonOption) ParseInputCaseOption { + return func(cfg *parseInputCaseConfig) { + cfg.cmpOpts = append(cfg.cmpOpts, opts...) + } +} + +func defaultParseInputCaseConfig() *parseInputCaseConfig { + return &parseInputCaseConfig{} +} + +// RunParseInputCase executes a single parse-input test case using the provided +// configuration. It mirrors the typical table-driven pattern while removing the +// boilerplate repeated across tests. The helper short-circuits as soon as an +// expected error is encountered. +func RunParseInputCase[T any](t *testing.T, tc ParseInputTestCase[T], opts ...ParseInputCaseOption) { + t.Helper() + + cfg := defaultParseInputCaseConfig() + for _, opt := range opts { + opt(cfg) + } + + if tc.CmdFactory == nil { + t.Fatalf("parse input case %q missing CmdFactory", tc.Name) + } + if tc.ParseInputFunc == nil { + t.Fatalf("parse input case %q missing ParseInputFunc", tc.Name) + } + + printer := print.NewPrinter() + cmd := tc.CmdFactory(&types.CmdParams{Printer: printer}) + if cmd == nil { + t.Fatalf("parse input case %q produced nil command", tc.Name) + } + if printer.Cmd == nil { + printer.Cmd = cmd + } + + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Fatalf("configure global flags: %v", err) + } + + // Set regular flag values. + for flag, value := range tc.Flags { + if err := cmd.Flags().Set(flag, value); err != nil { + AssertError(t, err, tc.WantErr) + return + } + } + + // Set repeated flag values. + for flag, values := range tc.RepeatFlags { + for _, value := range values { + if err := cmd.Flags().Set(flag, value); err != nil { + AssertError(t, err, tc.WantErr) + return + } + } + } + + // Test cobra argument validation. + if err := cmd.ValidateArgs(tc.Args); err != nil { + AssertError(t, err, tc.WantErr) + return + } + + // Test cobra required flags validation. + if err := cmd.ValidateRequiredFlags(); err != nil { + AssertError(t, err, tc.WantErr) + return + } + + // Test cobra flag group validation. + if err := cmd.ValidateFlagGroups(); err != nil { + AssertError(t, err, tc.WantErr) + return + } + + // Test parse input function. + got, err := tc.ParseInputFunc(printer, cmd, tc.Args) + if !AssertError(t, err, tc.WantErr) { + return + } + + AssertValue(t, got, tc.WantModel, cfg.cmpOpts...) +} diff --git a/internal/pkg/testutils/parse_input_test.go b/internal/pkg/testutils/parse_input_test.go new file mode 100755 index 000000000..32008eea3 --- /dev/null +++ b/internal/pkg/testutils/parse_input_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type parseInputTestModel struct { + Value string + Args []string + RepeatValue []string + hidden string +} + +func newTestCmdFactory(flagSetup func(*cobra.Command)) func(*types.CmdParams) *cobra.Command { + return func(*types.CmdParams) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + if flagSetup != nil { + flagSetup(cmd) + } + return cmd + } +} + +func TestRunParseInputCase(t *testing.T) { + sentinel := errors.New("parse failed") + tests := []struct { + name string + flagSetup func(*cobra.Command) + flags map[string]string + repeatFlags map[string][]string + args []string + cmpOpts []ParseInputCaseOption + wantModel *parseInputTestModel + wantErr any + parseFunc func(*print.Printer, *cobra.Command, []string) (*parseInputTestModel, error) + expectParseCall bool + }{ + { + name: "success", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().String("name", "", "") + }, + flags: map[string]string{"name": "edge"}, + cmpOpts: []ParseInputCaseOption{WithParseInputCmpOptions(WithAllowUnexported(parseInputTestModel{}))}, + wantModel: &parseInputTestModel{Value: "edge", hidden: "protected"}, + parseFunc: func(_ *print.Printer, cmd *cobra.Command, _ []string) (*parseInputTestModel, error) { + val, _ := cmd.Flags().GetString("name") + return &parseInputTestModel{Value: val, hidden: "protected"}, nil + }, + expectParseCall: true, + }, + { + name: "flag set failure", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().Int("count", 0, "") + }, + flags: map[string]string{"count": "invalid"}, + wantErr: "invalid syntax", + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + }, + expectParseCall: false, + }, + { + name: "flag group validation", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().String("first", "", "") + cmd.Flags().String("second", "", "") + cmd.MarkFlagsRequiredTogether("first", "second") + }, + flags: map[string]string{"first": "only"}, + wantErr: "must all be set", + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + }, + expectParseCall: false, + }, + { + name: "parse func error", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().Bool("ok", false, "") + }, + flags: map[string]string{"ok": "true"}, + wantErr: sentinel, + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return nil, sentinel + }, + expectParseCall: true, + }, + { + name: "args success", + flagSetup: func(cmd *cobra.Command) { + cmd.Args = cobra.ExactArgs(1) + }, + args: []string{"arg1"}, + cmpOpts: []ParseInputCaseOption{WithParseInputCmpOptions(WithAllowUnexported(parseInputTestModel{}))}, + wantModel: &parseInputTestModel{Args: []string{"arg1"}}, + parseFunc: func(_ *print.Printer, _ *cobra.Command, args []string) (*parseInputTestModel, error) { + return &parseInputTestModel{Args: args}, nil + }, + expectParseCall: true, + }, + { + name: "args validation failure", + flagSetup: func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs + }, + args: []string{"arg1"}, + wantErr: "unknown command", + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + }, + expectParseCall: false, + }, + { + name: "repeat flags success", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().StringSlice("tags", []string{}, "") + }, + repeatFlags: map[string][]string{"tags": {"tag1", "tag2"}}, + cmpOpts: []ParseInputCaseOption{WithParseInputCmpOptions(WithAllowUnexported(parseInputTestModel{}))}, + wantModel: &parseInputTestModel{RepeatValue: []string{"tag1", "tag2"}}, + parseFunc: func(_ *print.Printer, cmd *cobra.Command, _ []string) (*parseInputTestModel, error) { + val, _ := cmd.Flags().GetStringSlice("tags") + return &parseInputTestModel{RepeatValue: val}, nil + }, + expectParseCall: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmdFactory := newTestCmdFactory(tt.flagSetup) + var parseCalled bool + parseFn := tt.parseFunc + if parseFn == nil { + parseFn = func(*print.Printer, *cobra.Command, []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + } + } + + RunParseInputCase(t, ParseInputTestCase[*parseInputTestModel]{ + Name: tt.name, + Flags: tt.flags, + RepeatFlags: tt.repeatFlags, + Args: tt.args, + WantModel: tt.wantModel, + WantErr: tt.wantErr, + CmdFactory: cmdFactory, + ParseInputFunc: func(pr *print.Printer, cmd *cobra.Command, args []string) (*parseInputTestModel, error) { + parseCalled = true + return parseFn(pr, cmd, args) + }, + }, tt.cmpOpts...) + + if parseCalled != tt.expectParseCall { + t.Fatalf("parseCalled = %v, expect %v", parseCalled, tt.expectParseCall) + } + }) + } +}