From 1839b6148f7b6e0d27a02dee5bdca29cb3681451 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 27 Jul 2026 14:58:59 +0200 Subject: [PATCH 1/2] chore(edge): refactoring and migration to new SDK structure relates to STACKITCLI-357 --- docs/stackit_beta.md | 2 +- docs/stackit_beta_edge-cloud.md | 10 +- docs/stackit_beta_edge-cloud_instance.md | 14 +- ...stackit_beta_edge-cloud_instance_create.md | 8 +- ...stackit_beta_edge-cloud_instance_delete.md | 17 +- ...ackit_beta_edge-cloud_instance_describe.md | 18 +- docs/stackit_beta_edge-cloud_instance_list.md | 8 +- ...stackit_beta_edge-cloud_instance_update.md | 21 +- docs/stackit_beta_edge-cloud_kubeconfig.md | 6 +- ...ackit_beta_edge-cloud_kubeconfig_create.md | 24 +- docs/stackit_beta_edge-cloud_plans.md | 6 +- docs/stackit_beta_edge-cloud_plans_list.md | 12 +- docs/stackit_beta_edge-cloud_token.md | 6 +- docs/stackit_beta_edge-cloud_token_create.md | 20 +- go.mod | 2 +- go.sum | 4 +- internal/cmd/beta/edge/edge.go | 5 +- .../cmd/beta/edge/instance/create/create.go | 181 ++--- .../beta/edge/instance/create/create_test.go | 407 +++------- .../cmd/beta/edge/instance/delete/delete.go | 210 ++--- .../beta/edge/instance/delete/delete_test.go | 563 ++----------- .../beta/edge/instance/describe/describe.go | 154 +--- .../edge/instance/describe/describe_test.go | 562 +++---------- internal/cmd/beta/edge/instance/instance.go | 5 +- internal/cmd/beta/edge/instance/list/list.go | 107 +-- .../cmd/beta/edge/instance/list/list_test.go | 437 ++-------- .../cmd/beta/edge/instance/update/update.go | 246 ++---- .../beta/edge/instance/update/update_test.go | 562 +++---------- .../cmd/beta/edge/kubeconfig/create/create.go | 303 +++---- .../edge/kubeconfig/create/create_test.go | 748 ++++-------------- .../cmd/beta/edge/kubeconfig/kubeconfig.go | 5 +- internal/cmd/beta/edge/plans/list/list.go | 88 +-- .../cmd/beta/edge/plans/list/list_test.go | 430 +++------- internal/cmd/beta/edge/plans/plans.go | 5 +- internal/cmd/beta/edge/token/create/create.go | 247 ++---- .../cmd/beta/edge/token/create/create_test.go | 673 ++++------------ internal/cmd/beta/edge/token/token.go | 5 +- internal/pkg/services/edge/client/client.go | 32 +- .../pkg/services/edge/common/error/error.go | 85 -- .../services/edge/common/error/error_test.go | 180 ----- .../services/edge/common/instance/instance.go | 140 ---- .../edge/common/instance/instance_test.go | 348 -------- .../edge/common/kubeconfig/kubeconfig.go | 27 +- .../edge/common/kubeconfig/kubeconfig_test.go | 3 - .../services/edge/common/validation/input.go | 52 -- .../edge/common/validation/input_test.go | 83 -- internal/pkg/services/edge/utils/utils.go | 16 + .../pkg/services/edge/utils/utils_test.go | 86 ++ 48 files changed, 1532 insertions(+), 5641 deletions(-) delete mode 100755 internal/pkg/services/edge/common/error/error.go delete mode 100755 internal/pkg/services/edge/common/error/error_test.go delete mode 100644 internal/pkg/services/edge/common/instance/instance.go delete mode 100755 internal/pkg/services/edge/common/instance/instance_test.go delete mode 100644 internal/pkg/services/edge/common/validation/input.go delete mode 100755 internal/pkg/services/edge/common/validation/input_test.go create mode 100644 internal/pkg/services/edge/utils/utils.go create mode 100644 internal/pkg/services/edge/utils/utils_test.go diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 52221ace6..7046745ce 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -43,7 +43,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 cdn](./stackit_beta_cdn.md) - Manage CDN resources -* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake * [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (STACKIT File Storage) * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex diff --git a/docs/stackit_beta_edge-cloud.md b/docs/stackit_beta_edge-cloud.md index 399471609..4b61c553b 100644 --- a/docs/stackit_beta_edge-cloud.md +++ b/docs/stackit_beta_edge-cloud.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud -Provides functionality for edge services. +Provides functionality for Edge Cloud services. ### Synopsis @@ -30,8 +30,8 @@ stackit beta edge-cloud [flags] ### 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. +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for Edge Cloud instances. +* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for Edge Cloud kubeconfig. +* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for Edge Cloud service plans. +* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for Edge Cloud service token. diff --git a/docs/stackit_beta_edge-cloud_instance.md b/docs/stackit_beta_edge-cloud_instance.md index 8f2098058..09a773dc0 100644 --- a/docs/stackit_beta_edge-cloud_instance.md +++ b/docs/stackit_beta_edge-cloud_instance.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud instance -Provides functionality for edge instances. +Provides functionality for Edge Cloud instances. ### Synopsis @@ -29,10 +29,10 @@ stackit beta edge-cloud instance [flags] ### 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 +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. +* [stackit beta edge-cloud instance create](./stackit_beta_edge-cloud_instance_create.md) - Creates an Edge Cloud instance +* [stackit beta edge-cloud instance delete](./stackit_beta_edge-cloud_instance_delete.md) - Deletes an Edge Cloud instance +* [stackit beta edge-cloud instance describe](./stackit_beta_edge-cloud_instance_describe.md) - Describes an Edge Cloud instance +* [stackit beta edge-cloud instance list](./stackit_beta_edge-cloud_instance_list.md) - Lists Edge Cloud instances +* [stackit beta edge-cloud instance update](./stackit_beta_edge-cloud_instance_update.md) - Updates an Edge Cloud instance diff --git a/docs/stackit_beta_edge-cloud_instance_create.md b/docs/stackit_beta_edge-cloud_instance_create.md index fad42e2b0..b2cc1acf7 100644 --- a/docs/stackit_beta_edge-cloud_instance_create.md +++ b/docs/stackit_beta_edge-cloud_instance_create.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud instance create -Creates an edge instance +Creates an Edge Cloud instance ### Synopsis @@ -13,7 +13,7 @@ stackit beta edge-cloud instance create [flags] ### Examples ``` - Creates an edge instance with the name "xxx" and plan-id "yyy" + Creates an Edge Cloud instance with the name "xxx" and plan-id "yyy" $ stackit beta edge-cloud instance create --name "xxx" --plan-id "yyy" ``` @@ -23,7 +23,7 @@ stackit beta edge-cloud instance create [flags] -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. + --plan-id string Service plan configures the size of the Instance. ``` ### Options inherited from parent commands @@ -39,5 +39,5 @@ stackit beta edge-cloud instance create [flags] ### SEE ALSO -* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for Edge Cloud instances. diff --git a/docs/stackit_beta_edge-cloud_instance_delete.md b/docs/stackit_beta_edge-cloud_instance_delete.md index 20a900f8b..9d3f210d8 100644 --- a/docs/stackit_beta_edge-cloud_instance_delete.md +++ b/docs/stackit_beta_edge-cloud_instance_delete.md @@ -1,31 +1,26 @@ ## stackit beta edge-cloud instance delete -Deletes an edge instance +Deletes an Edge Cloud instance ### Synopsis Deletes a STACKIT Edge Cloud (STEC) instance. The instance will be deleted permanently. ``` -stackit beta edge-cloud instance delete [flags] +stackit beta edge-cloud instance delete INSTANCE_ID [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" + Delete an edge instance with ID "xxx" + $ stackit beta edge-cloud instance delete 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. + -h, --help Help for "stackit beta edge-cloud instance delete" ``` ### Options inherited from parent commands @@ -41,5 +36,5 @@ stackit beta edge-cloud instance delete [flags] ### SEE ALSO -* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for Edge Cloud instances. diff --git a/docs/stackit_beta_edge-cloud_instance_describe.md b/docs/stackit_beta_edge-cloud_instance_describe.md index e7621706b..e8808e7fa 100644 --- a/docs/stackit_beta_edge-cloud_instance_describe.md +++ b/docs/stackit_beta_edge-cloud_instance_describe.md @@ -1,31 +1,29 @@ ## stackit beta edge-cloud instance describe -Describes an edge instance +Describes an Edge Cloud instance ### Synopsis Describes a STACKIT Edge Cloud (STEC) instance. ``` -stackit beta edge-cloud instance describe [flags] +stackit beta edge-cloud instance describe INSTANCE_ID [flags] ``` ### Examples ``` - Describe an edge instance with id "xxx" - $ stackit beta edge-cloud instance describe --id + Describe an Edge Cloud instance with ID "xxx" + $ stackit beta edge-cloud instance describe xxx - Describe an edge instance with name "xxx" - $ stackit beta edge-cloud instance describe --name + Get details of an Edge Cloud instance with ID "xxx" in JSON format + $ stackit beta edge-cloud instance describe xxx --output-format json ``` ### 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. + -h, --help Help for "stackit beta edge-cloud instance describe" ``` ### Options inherited from parent commands @@ -41,5 +39,5 @@ stackit beta edge-cloud instance describe [flags] ### SEE ALSO -* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for Edge Cloud instances. diff --git a/docs/stackit_beta_edge-cloud_instance_list.md b/docs/stackit_beta_edge-cloud_instance_list.md index ab6a99842..493ec6dca 100644 --- a/docs/stackit_beta_edge-cloud_instance_list.md +++ b/docs/stackit_beta_edge-cloud_instance_list.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud instance list -Lists edge instances +Lists Edge Cloud instances ### Synopsis @@ -13,10 +13,10 @@ stackit beta edge-cloud instance list [flags] ### Examples ``` - Lists all edge instances of a given project + Lists all Edge Cloud 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 + Lists all Edge Cloud instances of a given project and limits the output to two instances $ stackit beta edge-cloud instance list --limit 2 ``` @@ -40,5 +40,5 @@ stackit beta edge-cloud instance list [flags] ### SEE ALSO -* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for Edge Cloud instances. diff --git a/docs/stackit_beta_edge-cloud_instance_update.md b/docs/stackit_beta_edge-cloud_instance_update.md index 935ac1f07..1fe66b6d9 100644 --- a/docs/stackit_beta_edge-cloud_instance_update.md +++ b/docs/stackit_beta_edge-cloud_instance_update.md @@ -1,26 +1,23 @@ ## stackit beta edge-cloud instance update -Updates an edge instance +Updates an Edge Cloud instance ### Synopsis Updates a STACKIT Edge Cloud (STEC) instance. ``` -stackit beta edge-cloud instance update [flags] +stackit beta edge-cloud instance update INSTANCE_ID [flags] ``` ### Examples ``` - Updates the description of an edge instance with id "xxx" - $ stackit beta edge-cloud instance update --id "xxx" --description "yyy" + Updates the description of an Edge Cloud instance with ID "xxx" + $ stackit beta edge-cloud instance update 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" + Updates the plan of an Edge Cloud instance with ID "xxx" + $ stackit beta edge-cloud instance update xxx --plan-id yyy ``` ### Options @@ -28,9 +25,7 @@ stackit beta edge-cloud instance update [flags] ``` -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. + --plan-id string Service plan configures the size of the Instance. ``` ### Options inherited from parent commands @@ -46,5 +41,5 @@ stackit beta edge-cloud instance update [flags] ### SEE ALSO -* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for Edge Cloud instances. diff --git a/docs/stackit_beta_edge-cloud_kubeconfig.md b/docs/stackit_beta_edge-cloud_kubeconfig.md index 8f875e404..3c90d6ac1 100644 --- a/docs/stackit_beta_edge-cloud_kubeconfig.md +++ b/docs/stackit_beta_edge-cloud_kubeconfig.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud kubeconfig -Provides functionality for edge kubeconfig. +Provides functionality for Edge Cloud kubeconfig. ### Synopsis @@ -29,6 +29,6 @@ stackit beta edge-cloud kubeconfig [flags] ### 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 +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. +* [stackit beta edge-cloud kubeconfig create](./stackit_beta_edge-cloud_kubeconfig_create.md) - Creates or updates a local kubeconfig file of an Edge Cloud instance diff --git a/docs/stackit_beta_edge-cloud_kubeconfig_create.md b/docs/stackit_beta_edge-cloud_kubeconfig_create.md index 8627501f7..7ebd2a03c 100644 --- a/docs/stackit_beta_edge-cloud_kubeconfig_create.md +++ b/docs/stackit_beta_edge-cloud_kubeconfig_create.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud kubeconfig create -Creates or updates a local kubeconfig file of an edge instance +Creates or updates a local kubeconfig file of an Edge Cloud instance ### Synopsis @@ -12,23 +12,23 @@ An expiration time can be set for the kubeconfig. The expiration time is set in 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] +stackit beta edge-cloud kubeconfig create kubeconfig for INSTANCE_ID [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 Cloud instance with instance ID "xxx". If the config exists in the kubeconfig file, the information will be updated. + $ stackit beta edge-cloud kubeconfig create 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" + Create or update a kubeconfig for the Edge Cloud instance with instance ID "xxx" in a custom filepath. + $ stackit beta edge-cloud kubeconfig create 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 + Get a kubeconfig for the Edge Cloud instance with instance ID "xxx" without writing it to a file and format the output as json. + $ stackit beta edge-cloud kubeconfig create 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 + Create a kubeconfig for the Edge Cloud instance with instance ID "xxx". This will replace your current kubeconfig file. + $ stackit beta edge-cloud kubeconfig create xxx --overwrite ``` ### Options @@ -38,8 +38,6 @@ stackit beta edge-cloud kubeconfig create [flags] -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. ``` @@ -57,5 +55,5 @@ stackit beta edge-cloud kubeconfig create [flags] ### SEE ALSO -* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for edge kubeconfig. +* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for Edge Cloud kubeconfig. diff --git a/docs/stackit_beta_edge-cloud_plans.md b/docs/stackit_beta_edge-cloud_plans.md index cf92d0a42..929fb6a5e 100644 --- a/docs/stackit_beta_edge-cloud_plans.md +++ b/docs/stackit_beta_edge-cloud_plans.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud plans -Provides functionality for edge service plans. +Provides functionality for Edge Cloud service plans. ### Synopsis @@ -29,6 +29,6 @@ stackit beta edge-cloud plans [flags] ### 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 +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. +* [stackit beta edge-cloud plans list](./stackit_beta_edge-cloud_plans_list.md) - Lists available Edge Cloud service plans diff --git a/docs/stackit_beta_edge-cloud_plans_list.md b/docs/stackit_beta_edge-cloud_plans_list.md index 8caba32ba..9f6e3f72a 100644 --- a/docs/stackit_beta_edge-cloud_plans_list.md +++ b/docs/stackit_beta_edge-cloud_plans_list.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud plans list -Lists available edge service plans +Lists available Edge Cloud service plans ### Synopsis @@ -13,11 +13,11 @@ 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 Cloud plans for a given project + $ stackit beta edge-cloud plans list - Lists all edge plans for a given project and limits the output to two plans - $ stackit beta edge-cloud plan list --limit 2 + Lists all Edge Cloud plans for a given project and limits the output to two plans + $ stackit beta edge-cloud plans list --limit=2 ``` ### Options @@ -40,5 +40,5 @@ stackit beta edge-cloud plans list [flags] ### SEE ALSO -* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for edge service plans. +* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for Edge Cloud service plans. diff --git a/docs/stackit_beta_edge-cloud_token.md b/docs/stackit_beta_edge-cloud_token.md index a6061a060..3eae3bfab 100644 --- a/docs/stackit_beta_edge-cloud_token.md +++ b/docs/stackit_beta_edge-cloud_token.md @@ -1,6 +1,6 @@ ## stackit beta edge-cloud token -Provides functionality for edge service token. +Provides functionality for Edge Cloud service token. ### Synopsis @@ -29,6 +29,6 @@ stackit beta edge-cloud token [flags] ### 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 +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. +* [stackit beta edge-cloud token create](./stackit_beta_edge-cloud_token_create.md) - Creates a token for an Edge Cloud instance diff --git a/docs/stackit_beta_edge-cloud_token_create.md b/docs/stackit_beta_edge-cloud_token_create.md index 139fa20d0..41392cebf 100644 --- a/docs/stackit_beta_edge-cloud_token_create.md +++ b/docs/stackit_beta_edge-cloud_token_create.md @@ -1,35 +1,33 @@ ## stackit beta edge-cloud token create -Creates a token for an edge instance +Creates a token for an Edge Cloud 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. +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(1h) 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] +stackit beta edge-cloud token create token for INSTANCE_ID [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 Cloud instance with instance ID "xxx". + $ stackit beta edge-cloud token create 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 + Create a token for the Edge Cloud instance with instance ID "xxx". The token will be valid for one day. + $ stackit beta edge-cloud token create xxx --expiration 1d ``` ### Options ``` - -e, --expiration string Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h. + -e, --expiration string Expiration time for the token, 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 @@ -45,5 +43,5 @@ stackit beta edge-cloud token create [flags] ### SEE ALSO -* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for edge service token. +* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for Edge Cloud service token. diff --git a/go.mod b/go.mod index 2353b9927..08933022b 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/authorization v0.12.0 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.16.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.21.0 - github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.3 + github.com/stackitcloud/stackit-sdk-go/services/edge v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/git v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.12.0 github.com/stackitcloud/stackit-sdk-go/services/intake v0.7.1 diff --git a/go.sum b/go.sum index 5ec24b922..4982b18cf 100644 --- a/go.sum +++ b/go.sum @@ -600,8 +600,8 @@ github.com/stackitcloud/stackit-sdk-go/services/cdn v1.16.0 h1:Wqxx0PDTL2F5gqI5j github.com/stackitcloud/stackit-sdk-go/services/cdn v1.16.0/go.mod h1:MHB1N3EQ9GuAduAQoNS+gb1MjrWJieszbpOso9TQv5s= github.com/stackitcloud/stackit-sdk-go/services/dns v0.21.0 h1:ZVkptfVCAqpaPWkE+WIopM9XdzqgbVcwmX5L1jZqqx8= github.com/stackitcloud/stackit-sdk-go/services/dns v0.21.0/go.mod h1:FiYSv3D9rzgEVzi8Mpq5oYZBosrasa5uUYqVdEIbM1U= -github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.3 h1:TxChb2qbO82JiQEBYClSSD5HZxqKeKJ6dIvkEUCJmbs= -github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.3/go.mod h1:KVWvQHb7CQLD9DzA4Np3WmakiCCsrHaCXvFEnOQ7nPk= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.13.0 h1:MD475tdG7oSRquUr4SA4MIevVTb82sBPM2Fkf5J7aE0= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.13.0/go.mod h1:Ylse6gqGJtsd5TVmvha+hoLd1QQHLKvhY5dO15+q5kg= github.com/stackitcloud/stackit-sdk-go/services/git v0.14.0 h1:VZBneGprCmHqckcSMPs3puBlK8rBpLMtYKmBktwdoVE= github.com/stackitcloud/stackit-sdk-go/services/git v0.14.0/go.mod h1:YZEL+gaK+ELn5E9VtK8yvz5RcmCBH+JkRpf6YbNVSbM= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.12.0 h1:H4V3H8qSKOaOalIrf4nAPDHhXnHYGs6SDGuK8Zj41Zo= diff --git a/internal/cmd/beta/edge/edge.go b/internal/cmd/beta/edge/edge.go index 35b5e0575..ba3466c6f 100644 --- a/internal/cmd/beta/edge/edge.go +++ b/internal/cmd/beta/edge/edge.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package edge import ( @@ -18,7 +15,7 @@ import ( func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "edge-cloud", - Short: "Provides functionality for edge services.", + Short: "Provides functionality for Edge Cloud services.", Long: "Provides functionality for STACKIT Edge Cloud (STEC) services.", Args: args.NoArgs, Run: utils.CmdHelp, diff --git a/internal/cmd/beta/edge/instance/create/create.go b/internal/cmd/beta/edge/instance/create/create.go index 6faac9cc0..99016ec48 100755 --- a/internal/cmd/beta/edge/instance/create/create.go +++ b/internal/cmd/beta/edge/instance/create/create.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package create import ( @@ -8,8 +5,10 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" - "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" + "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -19,34 +18,41 @@ import ( "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" ) -// 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. +const ( + displayNameFlag = "name" + descriptionFlag = "description" + planIdFlag = "plan-id" // UUID +) + +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName *string + Description string + PlanId *string +} + +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Creates an edge instance", + Short: "Creates an Edge Cloud 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)), + `Creates an Edge Cloud instance with the name "xxx" and plan-id "yyy"`, + `$ stackit beta edge-cloud instance create --name "xxx" --plan-id "yyy"`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -60,41 +66,28 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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 - } + 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) + req, err := buildRequest(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") + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create edge cloud instance: %w", err) } - instanceId := *resp.Id - // Wait for async operation, if async mode not enabled if !model.Async { err := spinner.Run(params.Printer, "Creating instance", func() error { - // The waiter handler needs a concrete concreteClient type. We can safely cast here as the real implementation will always match. - concreteClient, ok := apiClient.(*edge.APIClient) - if !ok { - return fmt.Errorf("failed to configure API concreteClient") - } - _, err = wait.CreateOrUpdateInstanceWaitHandler(ctx, concreteClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + _, err = wait.CreateOrUpdateInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, resp.Id).WaitWithContext(ctx) return err }) if err != nil { @@ -102,8 +95,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } } - // Handle output to printer - return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } @@ -111,118 +103,63 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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.CreateInstancePayload - - // 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) + cmd.Flags().StringP(displayNameFlag, "n", "", "The displayed name to distinguish multiple instances.") + cmd.Flags().StringP(descriptionFlag, "d", "", "A user chosen description to distinguish multiple instances.") + cmd.Flags().String(planIdFlag, "", "Service plan configures the size of the Instance.") - cobra.CheckErr(flags.MarkFlagsRequired(cmd, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)) + err := flags.MarkFlagsRequired(cmd, displayNameFlag, planIdFlag) + cobra.CheckErr(err) } -// Parse user input (arguments and/or flags) -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*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 { + displayNameValue := flags.FlagToStringPointer(p, cmd, displayNameFlag) + planIdValue := flags.FlagToStringValue(p, cmd, planIdFlag) + err := utils.ValidateUUID(planIdValue) + if err != nil { return nil, err } + descriptionValue := flags.FlagToStringValue(p, cmd, descriptionFlag) model := inputModel{ GlobalFlagModel: globalFlags, - DisplayName: *displayNameValue, + DisplayName: displayNameValue, Description: descriptionValue, - PlanId: *planIdValue, + 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 -} +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) (req edge.ApiCreateInstanceRequest, err error) { + req = apiClient.DefaultAPI.CreateInstance(ctx, model.ProjectId, model.Region) -// buildRequest constructs the spec that can be tested. -func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { - req := apiClient.CreateInstance(ctx, model.ProjectId, model.Region) - - // Build request payload payload := edge.CreateInstancePayload{ - DisplayName: &model.DisplayName, + DisplayName: *model.DisplayName, Description: &model.Description, - PlanId: &model.PlanId, + PlanId: *model.PlanId, } - req = req.CreateInstancePayload(payload) - - return &createRequestSpec{ - ProjectID: model.ProjectId, - Region: model.Region, - Payload: payload, - Execute: req.Execute, - }, nil + + return req.CreateInstancePayload(payload), nil } -// Output result based on the configured output format -func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, instance *edge.Instance) error { +func outputResult(p *print.Printer, model *inputModel, 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 fmt.Errorf("instance response is empty") } - return p.OutputResult(outputFormat, instance, func() error { + return p.OutputResult(model.OutputFormat, instance, func() error { operationState := "Created" - if async { + if model.Async { operationState = "Triggered creation of" } - p.Outputf("%s instance for project %q. Instance ID: %q.\n", operationState, projectLabel, utils.PtrString(instance.Id)) + p.Outputf("%s instance for project %q. Instance ID: %q.\n", operationState, projectLabel, 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 index 13e64a6ca..ea378f2e5 100755 --- a/internal/cmd/beta/edge/instance/create/create_test.go +++ b/internal/cmd/beta/edge/instance/create/create_test.go @@ -1,26 +1,19 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package create import ( "context" - "errors" - "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) type testCtxKey struct{} @@ -34,86 +27,17 @@ var ( 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) CreateInstancePayload(_ edge.CreateInstancePayload) edge.ApiCreateInstanceRequest { - // 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 { - createInstanceMock edge.ApiCreateInstanceRequest -} - -func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - if m.createInstanceMock != nil { - return m.createInstanceMock - } - 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) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - 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 -} + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} +) 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, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testName, + descriptionFlag: testDescription, + planIdFlag: testPlanId, } for _, mod := range mods { mod(flagValues) @@ -128,9 +52,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, - DisplayName: testName, + DisplayName: utils.Ptr(testName), Description: testDescription, - PlanId: testPlanId, + PlanId: utils.Ptr(testPlanId), } for _, mod := range mods { mod(model) @@ -138,227 +62,136 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func TestParseInput(t *testing.T) { - type args struct { - flags map[string]string - cmpOpts []testUtils.ValueComparisonOption +func fixtureRequest(mods ...func(request *edge.ApiCreateInstanceRequest)) edge.ApiCreateInstanceRequest { + request := testClient.DefaultAPI.CreateInstance(testCtx, testProjectId, testRegion) + request = request.CreateInstancePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) } + return request +} +func fixturePayload(mods ...func(payload *edge.CreateInstancePayload)) edge.CreateInstancePayload { + payload := edge.CreateInstancePayload{ + DisplayName: testName, + Description: utils.Ptr(testDescription), + PlanId: testPlanId, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { tests := []struct { - name string - wantErr any - want *inputModel - args args + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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" - }), - }, + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - name: "name missing", - wantErr: "required flag(s) \"name\" not set", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, commonInstance.DisplayNameFlag) - }), - }, + description: "no values", + flagValues: map[string]string{}, + isValid: false, }, { - 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" - }), - }, + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - name: "name too short", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.DisplayNameFlag] = "in" - }), - }, + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - name: "name invalid", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.DisplayNameFlag] = "1test" - }), - }, + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - name: "plan invalid", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.PlanIdFlag] = "invalid-uuid" - }), - }, + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, }, { - name: "description too long", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.DescriptionFlag] = strings.Repeat("a", 257) - }), - }, + description: "plan id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, planIdFlag) + }), + isValid: false, }, } 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...) + t.Run(tt.description, func(t *testing.T) { + testUtils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } func TestBuildRequest(t *testing.T) { - type args struct { - model *inputModel - client client.APIClient - } - tests := []struct { - name string - args args - want *createRequestSpec - }{ - { - name: "success", - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - createInstanceMock: &mockExecutable{}, - }, - }, - want: &createRequestSpec{ - ProjectID: testProjectId, - Region: testRegion, - Payload: edge.CreateInstancePayload{ - 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 + description string + model *inputModel + expectedRequest edge.ApiCreateInstanceRequest }{ { - name: "create success", - want: &edge.Instance{Id: &testInstanceId}, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - createInstanceMock: &mockExecutable{ - resp: &edge.Instance{Id: &testInstanceId}, - }, - }, - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, { - name: "create API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - createInstanceMock: &mockExecutable{ - executeFails: true, - }, + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, }, - }, + DisplayName: utils.Ptr(testName), + PlanId: utils.Ptr(testPlanId), + }, + expectedRequest: testClient.DefaultAPI. + CreateInstance(testCtx, testProjectId, testRegion). + CreateInstancePayload(edge.CreateInstancePayload{ + DisplayName: testName, + PlanId: testPlanId, + Description: utils.Ptr(""), + }), }, } 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 + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("cannot create request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, edge.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } - testUtils.AssertValue(t, got, tt.want) }) } } @@ -371,19 +204,19 @@ func TestOutputResult(t *testing.T) { } tests := []struct { - name string - wantErr error - args args + description string + wantErr bool + args args }{ { - name: "no instance", - wantErr: &commonErr.NoInstanceError{}, + description: "no instance", + wantErr: true, args: args{ model: fixtureInputModel(), }, }, { - name: "output json", + description: "output json", args: args{ model: fixtureInputModel(func(model *inputModel) { model.OutputFormat = print.JSONOutputFormat @@ -392,7 +225,7 @@ func TestOutputResult(t *testing.T) { }, }, { - name: "output yaml", + description: "output yaml", args: args{ model: fixtureInputModel(func(model *inputModel) { model.OutputFormat = print.YAMLOutputFormat @@ -401,19 +234,19 @@ func TestOutputResult(t *testing.T) { }, }, { - name: "output default", + description: "output default", args: args{ model: fixtureInputModel(), - instance: &edge.Instance{Id: &testInstanceId}, + instance: &edge.Instance{Id: testInstanceId}, }, }, } + params := testparams.NewTestParams() for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - params := testparams.NewTestParams() - - err := outputResult(params.Printer, tt.args.model.OutputFormat, tt.args.model.Async, tt.args.projectLabel, tt.args.instance) - testUtils.AssertError(t, err, tt.wantErr) + t.Run(tt.description, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.projectLabel, tt.args.instance); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } }) } } diff --git a/internal/cmd/beta/edge/instance/delete/delete.go b/internal/cmd/beta/edge/instance/delete/delete.go index c6998e782..064c5250a 100755 --- a/internal/cmd/beta/edge/instance/delete/delete.go +++ b/internal/cmd/beta/edge/instance/delete/delete.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package delete import ( @@ -8,74 +5,47 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" - "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" + "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api/wait" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/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" + edgeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" ) -// Struct to model user input (arguments and/or flags) +const ( + instanceIdArg = "INSTANCE_ID" +) + 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 + InstanceId string } -// 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. +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: "delete", - Short: "Deletes an edge instance", + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes an Edge Cloud instance", Long: "Deletes a STACKIT Edge Cloud (STEC) instance. The instance will be deleted permanently.", - Args: args.NoArgs, + Args: args.SingleArg(instanceIdArg, nil), 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)), + `Delete an edge instance with ID "xxx"`, + `$ stackit beta edge-cloud instance delete xxx`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -89,155 +59,67 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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 - } + instanceLabel, err := edgeUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId } - // Call API - err = run(ctx, model, apiClient) + prompt := fmt.Sprintf("Are you sure you want to delete instance %q of project %q?", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) if err != nil { return err } - // Wait for async operation, if async mode not enabled - operationState := "Triggered deletion of" + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Edge Cloud instance: %w", err) + } + if !model.Async { err := spinner.Run(params.Printer, "Deleting instance", func() error { - // Determine identifier and waiter to use - waiterFactory, err := getWaiterFactory(ctx, model) - if err != nil { - return err - } - // The waiter factory needs a concrete concreteClient type. We can safely cast here as the real implementation will always match. - concreteClient, ok := apiClient.(*edge.APIClient) - if !ok { - return fmt.Errorf("failed to configure API concreteClient") - } - waiter := waiterFactory(concreteClient) - _, err = waiter.WaitWithContext(ctx) + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) return err }) - if err != nil { - return fmt.Errorf("wait for edge instance deletion: %w", err) + return fmt.Errorf("wait for Edge Cloud instance deletion: %w", err) } - operationState = "Deleted" } - params.Printer.Info("%s instance with %q %q of project %q.\n", operationState, model.identifier.Flag, model.identifier.Value, projectLabel) - + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Info("%s instance %q of project %q.\n", operationState, instanceLabel, 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...) -} +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] -// 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{} + return nil, &errors.ProjectIdError{} } - // Generate input model based on chosen flags model := inputModel{ GlobalFlagModel: globalFlags, + InstanceId: instanceId, } - // 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) - } +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) edge.ApiDeleteInstanceRequest { + req := apiClient.DefaultAPI.DeleteInstance(ctx, model.ProjectId, model.Region, model.InstanceId) + return req } diff --git a/internal/cmd/beta/edge/instance/delete/delete_test.go b/internal/cmd/beta/edge/instance/delete/delete_test.go index 3e72a71d1..84c8391dd 100755 --- a/internal/cmd/beta/edge/instance/delete/delete_test.go +++ b/internal/cmd/beta/edge/instance/delete/delete_test.go @@ -1,119 +1,43 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package delete import ( "context" - "errors" - "net/http" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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/testutils" ) type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testProjectId = uuid.NewString() - testRegion = "eu01" + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testRegion = "eu01" - testInstanceId = "instance" - testDisplayName = "test" + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} ) -// 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") +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, } - 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 + for _, mod := range mods { + mod(argValues) } - return &mockExecutable{} -} - -// Unused methods to satisfy the client.APIClient interface. -func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - 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) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - 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 + return argValues } -func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - globalflags.ProjectIdFlag: testProjectId, - globalflags.RegionFlag: testRegion, - commonInstance.InstanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -121,437 +45,124 @@ func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { 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, - } - } - +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, - identifier: identifier, + InstanceId: testInstanceId, } - 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 fixtureRequest(mods ...func(request *edge.ApiDeleteInstanceRequest)) edge.ApiDeleteInstanceRequest { + request := testClient.DefaultAPI.DeleteInstance(testCtx, testProjectId, testRegion, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request } 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 + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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) - }), - }, + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - name: "project id empty", - wantErr: "value cannot be empty", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[globalflags.ProjectIdFlag] = "" - }), - }, + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, }, { - name: "project id invalid", - wantErr: "invalid UUID length", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" - }), - }, + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - name: "instance id empty", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "" - }), - }, + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, }, { - name: "instance id too long", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" - }), - }, + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - name: "instance id too short", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "id" - }), - }, + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - name: "name too short", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, commonInstance.InstanceIdFlag) - flagValues[commonInstance.DisplayNameFlag] = "foo" - }), - }, + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - name: "name too long", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, commonInstance.InstanceIdFlag) - flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" - }), - }, + description: "instance id invalid", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, }, } 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) + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } func TestBuildRequest(t *testing.T) { - type args struct { - model *inputModel - client client.APIClient - } tests := []struct { - name string - args args - want *deleteRequestSpec - wantErr error + description string + model *inputModel + expectedRequest edge.ApiDeleteInstanceRequest }{ { - 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"} - }), - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, } 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") + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, edge.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } }) } diff --git a/internal/cmd/beta/edge/instance/describe/describe.go b/internal/cmd/beta/edge/instance/describe/describe.go index d4ac397b5..78a58521e 100755 --- a/internal/cmd/beta/edge/instance/describe/describe.go +++ b/internal/cmd/beta/edge/instance/describe/describe.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package describe import ( @@ -8,63 +5,49 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "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" ) +const ( + instanceIdArg = "INSTANCE_ID" +) + 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) + InstanceId string } -// 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. +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: "describe", - Short: "Describes an edge instance", + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Describes an Edge Cloud instance", Long: "Describes a STACKIT Edge Cloud (STEC) instance.", - Args: args.NoArgs, + Args: args.SingleArg(instanceIdArg, nil), 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)), + `Describe an Edge Cloud instance with ID "xxx"`, + `$ stackit beta edge-cloud instance describe xxx`), examples.NewExample( - fmt.Sprintf(`Describe an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), - fmt.Sprintf(`$ stackit beta edge-cloud instance describe --%s `, commonInstance.DisplayNameFlag)), + `Get details of an Edge Cloud instance with ID "xxx" in JSON format`, + "$ stackit beta edge-cloud instance describe xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -76,123 +59,62 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - resp, err := run(ctx, model, apiClient) + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() if err != nil { - return err + return fmt.Errorf("read Edge Cloud instance: %w", 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...) -} +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] -// 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{} + return nil, &errors.ProjectIdError{} } - // Generate input model based on chosen flags model := inputModel{ GlobalFlagModel: globalFlags, + InstanceId: instanceId, } - // 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 +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) edge.ApiGetInstanceRequest { + req := apiClient.DefaultAPI.GetInstance(ctx, model.ProjectId, model.Region, model.InstanceId) + return req } -// 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 fmt.Errorf("instance response is empty") } 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.AddRow("ID", instance.Id) table.AddSeparator() - table.AddRow("NAME", utils.PtrString(instance.DisplayName)) + table.AddRow("NAME", instance.DisplayName) table.AddSeparator() if instance.HasDescription() { table.AddRow("DESCRIPTION", utils.PtrString(instance.Description)) table.AddSeparator() } - table.AddRow("UI", utils.PtrString(instance.FrontendUrl)) + table.AddRow("CREATED", instance.Created) + table.AddSeparator() + table.AddRow("UI", instance.FrontendUrl) table.AddSeparator() - table.AddRow("STATE", utils.PtrString(instance.Status)) + table.AddRow("STATE", instance.Status) table.AddSeparator() - table.AddRow("PLAN", utils.PtrString(instance.PlanId)) + table.AddRow("PLAN", instance.PlanId) table.AddSeparator() err := table.Display(p) diff --git a/internal/cmd/beta/edge/instance/describe/describe_test.go b/internal/cmd/beta/edge/instance/describe/describe_test.go index 4ad849200..17d49030e 100755 --- a/internal/cmd/beta/edge/instance/describe/describe_test.go +++ b/internal/cmd/beta/edge/instance/describe/describe_test.go @@ -1,28 +1,17 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package describe import ( "context" - "errors" - "net/http" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" - testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -32,91 +21,25 @@ var ( 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 -} + testInstanceId = uuid.NewString() -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 -} + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} +) -func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { - if m.getInstanceMock != nil { - return m.getInstanceMock +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, } - return &mockExecutable{} -} - -func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { - if m.getInstanceByNameMock != nil { - return m.getInstanceByNameMock + for _, mod := range mods { + mod(argValues) } - return &mockExecutable{} -} - -// Unused methods to satisfy the interface -func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - return nil -} -func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - 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 + return argValues } 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) @@ -124,452 +47,159 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st 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 { +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, + InstanceId: testInstanceId, } - - 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 +func fixtureRequest(mods ...func(request *edge.ApiGetInstanceRequest)) edge.ApiGetInstanceRequest { + request := testClient.DefaultAPI.GetInstance(testCtx, testProjectId, testRegion, testInstanceId) + for _, mod := range mods { + mod(&request) } + return request +} +func TestParseInput(t *testing.T) { tests := []struct { - name string - wantErr any - want *inputModel - args args + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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) - }), - }, + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - 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" - }), - }, + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, }, { - 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{}), - }, - }, + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - name: "instanceId empty", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "" - }), - }, + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, }, { - name: "instanceId too long", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" - }), - }, + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - name: "instanceId too short", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "id" - }), - }, + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - name: "name too short", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, commonInstance.InstanceIdFlag) - flagValues[commonInstance.DisplayNameFlag] = "foo" - }), - }, + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - name: "name too long", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, commonInstance.InstanceIdFlag) - flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" - }), - }, + description: "instance id invalid", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, }, } 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...) + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } -func TestRun(t *testing.T) { +func TestOutputResult(t *testing.T) { type args struct { - model *inputModel - client client.APIClient + outputFormat string + instance *edge.Instance } - tests := []struct { name string - wantErr error - want *edge.Instance args args + wantErr bool }{ { - 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: "empty", + args: args{}, + wantErr: true, }, { - name: "identifier invalid", - wantErr: &commonErr.InvalidIdentifierError{}, + name: "instance as argument", args: args{ - model: fixtureInputModel(false, func(model *inputModel) { - model.identifier = &commonValidation.Identifier{ - Flag: "unknown-flag", - Value: "some-value", - } - }), - client: &mockAPIClient{}, + instance: &edge.Instance{}, }, + wantErr: false, }, } + params := testparams.NewTestParams() 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 + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } - - 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) { - params := testparams.NewTestParams() - - err := outputResult(params.Printer, 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 + description string + model *inputModel + expectedRequest edge.ApiGetInstanceRequest }{ { - 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{}, - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, } 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 + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, edge.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } - 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 index 748371cda..3a7f24f4b 100644 --- a/internal/cmd/beta/edge/instance/instance.go +++ b/internal/cmd/beta/edge/instance/instance.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package instance import ( @@ -20,7 +17,7 @@ import ( func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", - Short: "Provides functionality for edge instances.", + Short: "Provides functionality for Edge Cloud instances.", Long: "Provides functionality for STACKIT Edge Cloud (STEC) instance management.", Args: args.NoArgs, Run: utils.CmdHelp, diff --git a/internal/cmd/beta/edge/instance/list/list.go b/internal/cmd/beta/edge/instance/list/list.go index 3b728ee2d..4c5e6054d 100755 --- a/internal/cmd/beta/edge/instance/list/list.go +++ b/internal/cmd/beta/edge/instance/list/list.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package list import ( @@ -8,7 +5,7 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -20,50 +17,38 @@ import ( "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" ) 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 +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "Lists edge instances", + Short: "Lists Edge Cloud 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`, + `Lists all Edge Cloud 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)), + `Lists all Edge Cloud instances of a given project and limits the output to two instances`, + `$ stackit beta edge-cloud instance list --limit 2`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -77,17 +62,23 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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) + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() if err != nil { - return err + return fmt.Errorf("get Edge Cloud instances: %w", err) } + instances := resp.Instances - return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = instances[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -99,14 +90,12 @@ 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) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*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{ @@ -120,71 +109,31 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { 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.ListInstances(ctx, model.ProjectId, model.Region) - - return &listRequestSpec{ - ProjectID: model.ProjectId, - Region: model.Region, - Limit: model.Limit, - Execute: req.Execute, - }, nil +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) edge.ApiListInstancesRequest { + req := apiClient.DefaultAPI.ListInstances(ctx, model.ProjectId, model.Region) + return req } -// 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)) + instance.Id, + instance.DisplayName, + instance.FrontendUrl, + instance.Status) } err := table.Display(p) if err != nil { diff --git a/internal/cmd/beta/edge/instance/list/list_test.go b/internal/cmd/beta/edge/instance/list/list_test.go index 8a9188370..a95d1a29a 100755 --- a/internal/cmd/beta/edge/instance/list/list_test.go +++ b/internal/cmd/beta/edge/instance/list/list_test.go @@ -1,23 +1,17 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package list import ( "context" - "errors" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" - testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -27,85 +21,15 @@ 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 { - listInstancesMock edge.ApiListInstancesRequest -} - -func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - if m.listInstancesMock != nil { - return m.listInstancesMock - } - return &mockExecutable{} -} - -// Unused methods to satisfy the interface -func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - 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 -} + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} +) 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) @@ -120,6 +44,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, + Limit: utils.Ptr(int64(10)), } for _, mod := range mods { mod(model) @@ -127,334 +52,136 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func TestParseInput(t *testing.T) { - type args struct { - flags map[string]string - cmpOpts []testUtils.ValueComparisonOption +func fixtureRequest(mods ...func(request *edge.ApiListInstancesRequest)) edge.ApiListInstancesRequest { + request := testClient.DefaultAPI.ListInstances(testCtx, testProjectId, testRegion) + for _, mod := range mods { + mod(&request) } + return request +} +func TestParseInput(t *testing.T) { tests := []struct { - name string - wantErr any - want *inputModel - args args + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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] = "" - }), - }, + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - name: "project id invalid", - wantErr: "invalid UUID length", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" - }), - }, + description: "no values", + flagValues: map[string]string{}, + isValid: false, }, { - 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{}, - }, + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - 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{}, - }, + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - 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{}, - }, + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - name: "list success with no items", - want: []edge.Instance{}, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - listInstancesMock: &mockExecutable{ - executeResp: &edge.InstanceList{Instances: &[]edge.Instance{}}, - }, - }, - }, + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, }, { - name: "list API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - listInstancesMock: &mockExecutable{ - executeFails: true, - }, - }, - }, + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, }, } 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) + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } func TestOutputResult(t *testing.T) { type args struct { - model *inputModel - instances []edge.Instance + outputFormat string projectLabel string + instances []edge.Instance } - tests := []struct { name string - wantErr error args args + wantErr bool }{ { - 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: "empty", + args: args{}, + wantErr: false, }, { - name: "output default with no instances", + name: "empty instance in instances slice", args: args{ - model: fixtureInputModel(), - instances: []edge.Instance{}, - projectLabel: "test-project", + instances: []edge.Instance{{}}, }, + wantErr: false, }, } + params := testparams.NewTestParams() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - params := testparams.NewTestParams() - - err := outputResult(params.Printer, tt.args.model.OutputFormat, tt.args.projectLabel, tt.args.instances) - testUtils.AssertError(t, err, tt.wantErr) + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", 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 + description string + model *inputModel + expectedRequest edge.ApiListInstancesRequest }{ { - name: "success", - want: &listRequestSpec{ - ProjectID: testProjectId, - Region: testRegion, - }, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - listInstancesMock: &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{ - listInstancesMock: &mockExecutable{}, - }, - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, } 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 + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, edge.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } - 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 index 2a6d3f6dd..dfbc81415 100755 --- a/internal/cmd/beta/edge/instance/update/update.go +++ b/internal/cmd/beta/edge/instance/update/update.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package update import ( @@ -9,6 +6,8 @@ import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "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" @@ -17,74 +16,50 @@ import ( "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" + edgeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/utils" "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" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" + "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api/wait" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" ) -// Struct to model user input (arguments and/or flags) +const ( + instanceIdArg = "INSTANCE_ID" + + descriptionFlag = "description" + planIdFlag = "plan-id" +) + type inputModel struct { *globalflags.GlobalFlagModel - identifier *commonValidation.Identifier + InstanceId string 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. +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: "update", - Short: "Updates an edge instance", + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates an Edge Cloud instance", Long: "Updates a STACKIT Edge Cloud (STEC) instance.", - Args: args.NoArgs, + Args: args.SingleArg(instanceIdArg, nil), 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)), + `Updates the description of an Edge Cloud instance with ID "xxx"`, + `$ stackit beta edge-cloud instance update xxx --description yyy`), 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)), + `Updates the plan of an Edge Cloud instance with ID "xxx"`, + `$ stackit beta edge-cloud instance update xxx --plan-id yyy`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -98,187 +73,98 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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 - } + instanceLabel, err := edgeUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to update the Edge Cloud instance %q of project %q?", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API - err = run(ctx, model, apiClient) + req, err := buildRequest(ctx, model, apiClient) if err != nil { return err } + err = req.Execute() + if err != nil { + return fmt.Errorf("update Edge Cloud instance: %w", err) + } + instanceId := model.InstanceId - // 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 err := spinner.Run(params.Printer, "Updating instance", func() error { - // Determine identifier and waiter to use - waiterFactory, err := getWaiterFactory(ctx, model) - if err != nil { - return err - } - // The waiter handler needs a concrete concreteClient type. We can safely cast here as the real implementation will always match. - concreteClient, ok := apiClient.(*edge.APIClient) - if !ok { - return fmt.Errorf("failed to configure API concreteClient") - } - waiter := waiterFactory(concreteClient) - _, err = waiter.WaitWithContext(ctx) + _, err = wait.CreateOrUpdateInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) return err }) - if err != nil { - return fmt.Errorf("wait for edge instance update: %w", err) + return fmt.Errorf("wait for SQLServer Flex instance update: %w", err) } - operationState = "Updated" } - params.Printer.Info("%s instance with %q %q of project %q.\n", operationState, model.identifier.Flag, model.identifier.Value, projectLabel) - + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) 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...) + cmd.Flags().StringP(descriptionFlag, "d", "", "A user chosen description to distinguish multiple instances.") + cmd.Flags().String(planIdFlag, "", "Service plan configures the size of the Instance.") // Make sure at least one updatable field is provided, otherwise it would be a no-op - updatedFields := []string{commonInstance.DescriptionFlag, commonInstance.PlanIdFlag} + updatedFields := []string{descriptionFlag, planIdFlag} cmd.MarkFlagsOneRequired(updatedFields...) } // Parse user input (arguments and/or flags) -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + 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) + planIdValue := flags.FlagToStringValue(p, cmd, planIdFlag) + err := utils.ValidateUUID(planIdValue) 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 - } + 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 - } + description := flags.FlagToStringPointer(p, cmd, descriptionFlag) + model.InstanceId = instanceId + model.Description = description - // 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 -} +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) (req edge.ApiUpdateInstanceRequest, err error) { + req = apiClient.DefaultAPI.UpdateInstance(ctx, model.ProjectId, model.Region, model.InstanceId) -// 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("") + payload := edge.UpdateInstancePayload{ + Description: model.Description, + PlanId: model.PlanId, } - 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) - } + return req.UpdateInstancePayload(payload), nil } diff --git a/internal/cmd/beta/edge/instance/update/update_test.go b/internal/cmd/beta/edge/instance/update/update_test.go index c5e3e92a8..797f85df6 100755 --- a/internal/cmd/beta/edge/instance/update/update_test.go +++ b/internal/cmd/beta/edge/instance/update/update_test.go @@ -1,27 +1,17 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package update import ( "context" - "errors" - "net/http" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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/utils" ) type testCtxKey struct{} @@ -30,107 +20,29 @@ var ( testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") testProjectId = uuid.NewString() testRegion = "eu01" - testInstanceId = "instance" - testDisplayName = "test" + testInstanceId = uuid.NewString() 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 -} + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} +) -func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { - if m.updateInstanceMock != nil { - return m.updateInstanceMock +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, } - return &mockExecutable{} -} - -func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { - if m.updateInstanceByNameMock != nil { - return m.updateInstanceByNameMock + for _, mod := range mods { + mod(argValues) } - return &mockExecutable{} -} - -// Unused methods to satisfy the interface -func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - 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) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - 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 + return argValues } 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, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + descriptionFlag: testDescription, + planIdFlag: testPlanId, } for _, mod := range mods { mod(flagValues) @@ -138,404 +50,156 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st 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 { +func fixtureInputModel(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, - } + InstanceId: testInstanceId, + Description: utils.Ptr(testDescription), + PlanId: utils.Ptr(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: "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 fixtureRequest(mods ...func(request *edge.ApiUpdateInstanceRequest)) edge.ApiUpdateInstanceRequest { + request := testClient.DefaultAPI.UpdateInstance(testCtx, testProjectId, testRegion, testInstanceId) + request = request.UpdateInstancePayload(edge.UpdateInstancePayload{ + Description: utils.Ptr(testDescription), + PlanId: utils.Ptr(testPlanId), + }) + for _, mod := range mods { + mod(&request) } + return request } -func TestBuildRequest(t *testing.T) { - type args struct { - model *inputModel - client client.APIClient - } +func TestParseInput(t *testing.T) { tests := []struct { - name string - args args - want *updateRequestSpec - wantErr error + description string + argValues []string + flagValues map[string]string + wantErr any + isValid bool + expectedModel *inputModel }{ { - 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, - }, - }, + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - 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, - }, - }, + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, }, { - name: "no identifier", - args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { - model.identifier = nil - }), - client: &mockAPIClient{}, - }, - wantErr: &commonErr.NoIdentifierError{}, + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - 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{}, - }, - }, + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, }, { - name: "update by name success", - args: args{ - model: fixtureByNameInputModel(), - client: &mockAPIClient{ - updateInstanceByNameMock: &mockExecutable{}, - }, - }, + description: "no update flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + delete(flagValues, planIdFlag) + }), + isValid: false, }, { - name: "no id or name", - wantErr: &commonErr.NoIdentifierError{}, - args: args{ - model: fixtureInputModel(false, func(model *inputModel) { - model.identifier = nil - }), - client: &mockAPIClient{}, - }, + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - name: "instance not found error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByIdInputModel(), - client: &mockAPIClient{ - updateInstanceMock: &mockExecutable{ - executeNotFound: true, - }, - }, - }, + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - name: "update by id API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByIdInputModel(), - client: &mockAPIClient{ - updateInstanceMock: &mockExecutable{ - executeFails: true, - }, - }, - }, + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - name: "update by name API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByNameInputModel(), - client: &mockAPIClient{ - updateInstanceByNameMock: &mockExecutable{ - executeFails: true, - }, - }, - }, + description: "instance id invalid", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - 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{}, - }, + description: "plan id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planIdFlag] = "invalid-uuid" + }), + isValid: false, }, } 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) + t.Run(tt.description, func(t *testing.T) { + testUtils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } -func TestGetWaiterFactory(t *testing.T) { - type args struct { - model *inputModel - } - +func TestBuildRequest(t *testing.T) { tests := []struct { - name string - wantErr error - want bool - args args + description string + model *inputModel + expectedRequest edge.ApiUpdateInstanceRequest + isValid bool }{ { - 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" - }), - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + isValid: true, }, } 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 + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) } - if tt.want && got == nil { - t.Fatal("expected non-nil waiter factory") - } - if !tt.want && got != nil { - t.Fatal("expected nil waiter factory") + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, edge.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } }) } diff --git a/internal/cmd/beta/edge/kubeconfig/create/create.go b/internal/cmd/beta/edge/kubeconfig/create/create.go index 7f7ee4d06..78274cb84 100755 --- a/internal/cmd/beta/edge/kubeconfig/create/create.go +++ b/internal/cmd/beta/edge/kubeconfig/create/create.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package create import ( @@ -10,9 +7,14 @@ import ( "github.com/goccy/go-yaml" "github.com/spf13/cobra" - "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" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" + "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + edgeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -21,16 +23,25 @@ import ( "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" ) +const ( + instanceIdArg = "INSTANCE_ID" + + expirationFlag = "expiration" + disableWritingFlag = "disable-writing" + filepathFlag = "filepath" + overwriteFlag = "overwrite" + switchContextFlag = "switch-context" + + expirationSecondsDefault = 3600 // 60 * 60 seconds = 1 hour +) + type inputModel struct { *globalflags.GlobalFlagModel - identifier *commonValidation.Identifier + InstanceId string DisableWriting bool Filepath *string Overwrite bool @@ -38,89 +49,38 @@ type inputModel struct { 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. +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: "create", - Short: "Creates or updates a local kubeconfig file of an edge instance", + Use: fmt.Sprintf("create kubeconfig for %s", instanceIdArg), + Short: "Creates or updates a local kubeconfig file of an Edge Cloud 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), + fmt.Sprintf("You can override this behavior by specifying a custom filepath with the --%s flag or disable writing with the --%s flag.", filepathFlag, 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.", expirationSecondsDefault), "Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units."), - Args: args.NoArgs, + Args: args.SingleArg(instanceIdArg, nil), 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)), + `Create or update a kubeconfig for the Edge Cloud instance with instance ID "xxx". If the config exists in the kubeconfig file, the information will be updated.`, + `$ stackit beta edge-cloud kubeconfig create xxx`), 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)), + `Create or update a kubeconfig for the Edge Cloud instance with instance ID "xxx" in a custom filepath.`, + `$ stackit beta edge-cloud kubeconfig create xxx --filepath yyy`), 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)), + `Get a kubeconfig for the Edge Cloud instance with instance ID "xxx" without writing it to a file and format the output as json.`, + `$ stackit beta edge-cloud kubeconfig create xxx --disable-writing --output-format json`), 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)), + `Create a kubeconfig for the Edge Cloud instance with instance ID "xxx". This will replace your current kubeconfig file.`, + `$ stackit beta edge-cloud kubeconfig create xxx --overwrite`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -131,20 +91,58 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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) + 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 + } + + instanceLabel, err := edgeUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + if !model.DisableWriting { + var prompt string + if model.Overwrite { + prompt = fmt.Sprintf("Are you sure you want to create a kubeconfig for instance %q of project %q? This will OVERWRITE your current kubeconfig file, if it exists.", instanceLabel, projectLabel) + } else { + prompt = fmt.Sprintf("Are you sure you want to update your kubeconfig for instance %q of project %q? This will update your kubeconfig file. \nIf the kubeconfig file does not exist, it will create a new one.", instanceLabel, projectLabel) + } + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build kubeconfig create request: %w", err) + } + respKubeconfig, err := req.Execute() + if err != nil { + return fmt.Errorf("create kubeconfig for Edge Cloud instance: %w", err) + } + if respKubeconfig.Kubeconfig == nil { + return fmt.Errorf("no kubeconfig returned from the API") + } + + var expiration = int64(model.Expiration) // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + err = spinner.Run(params.Printer, "Creating kubeconfig", func() error { + _, err = wait.KubeconfigWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, &expiration).WaitWithContext(ctx) return err + }) + if err != nil { + return fmt.Errorf("wait for kubeconfig creation: %w", err) } // Handle file operations or output to printer - return outputResult(params.Printer, model.OutputFormat, model, kubeconfig) + return outputResult(params.Printer, model.OutputFormat, model, respKubeconfig) }, } @@ -153,23 +151,20 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } 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 + cmd.Flags().Bool(disableWritingFlag, false, "Disable writing the kubeconfig to a file.") + cmd.Flags().StringP(filepathFlag, "f", "", "Path to the kubeconfig file. A default is chosen by Kubernetes if not set.") + cmd.Flags().StringP(expirationFlag, "e", "", "Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h.") + cmd.Flags().Bool(overwriteFlag, false, "Force overwrite the kubeconfig file if it exists.") + cmd.Flags().Bool(switchContextFlag, false, "Switch to the context in the kubeconfig file to the new context.") + + cmd.MarkFlagsMutuallyExclusive(disableWritingFlag, filepathFlag) // DisableWriting xor Filepath + cmd.MarkFlagsMutuallyExclusive(disableWritingFlag, overwriteFlag) // DisableWriting xor Overwrite } // Parse user input (arguments and/or flags) -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -178,47 +173,41 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { // 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 + InstanceId: instanceId, + Filepath: flags.FlagToStringPointer(p, cmd, filepathFlag), + Overwrite: flags.FlagToBoolValue(p, cmd, overwriteFlag), + SwitchContext: flags.FlagToBoolValue(p, cmd, switchContextFlag), } - 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 expString := flags.FlagToStringPointer(p, cmd, expirationFlag); expString != nil { + expTime, err := sdkUtils.ConvertToSeconds(*expString) if err != nil { return nil, &cliErr.FlagValidationError{ - Flag: commonKubeconfig.ExpirationFlag, + Flag: expirationFlag, Details: err.Error(), } } if err := commonKubeconfig.ValidateExpiration(&expTime); err != nil { return nil, &cliErr.FlagValidationError{ - Flag: commonKubeconfig.ExpirationFlag, + Flag: expirationFlag, Details: err.Error(), } } model.Expiration = expTime } else { // Default expiration is 1 hour - defaultExp := uint64(commonKubeconfig.ExpirationSecondsDefault) + defaultExp := uint64(expirationSecondsDefault) model.Expiration = defaultExp } - disableWriting := flags.FlagToBoolValue(p, cmd, commonKubeconfig.DisableWritingFlag) + disableWriting := flags.FlagToBoolValue(p, cmd, 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, + Flag: disableWritingFlag, Details: fmt.Sprintf("must be used with --%s", globalflags.OutputFormatFlag.Name()), } } @@ -235,89 +224,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { 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) - } +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) (edge.ApiGetKubeconfigByInstanceIdRequest, error) { + req := apiClient.DefaultAPI.GetKubeconfigByInstanceId(ctx, model.ProjectId, model.Region, model.InstanceId) + return req.ExpirationSeconds(int64(model.Expiration)), nil // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe } -// 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 + kubeconfigMap := kubeconfig.Kubeconfig // Determine output format for terminal or file output var format string @@ -365,7 +282,7 @@ func outputResult(p *print.Printer, outputFormat string, model *inputModel, kube } // Inform the user about the successful write operation - p.Outputf("Wrote kubeconfig for instance %q to %q.\n", model.identifier.Value, *path) + p.Outputf("Wrote kubeconfig for instance %q to %q.\n", model.InstanceId, *path) if model.SwitchContext { p.Outputln("Switched context as requested.") @@ -392,6 +309,6 @@ func marshalKubeconfig(kubeconfigMap map[string]interface{}, format string) (str } return string(kubeconfigYAML), nil default: - return "", fmt.Errorf("%w: %s", commonErr.NewNoIdentifierError(""), format) + return "", fmt.Errorf("format is not JSON or YAML: %s", format) } } diff --git a/internal/cmd/beta/edge/kubeconfig/create/create_test.go b/internal/cmd/beta/edge/kubeconfig/create/create_test.go index 051e470f8..7451e9908 100755 --- a/internal/cmd/beta/edge/kubeconfig/create/create_test.go +++ b/internal/cmd/beta/edge/kubeconfig/create/create_test.go @@ -1,42 +1,30 @@ -// 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/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" - testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testProjectId = uuid.NewString() - testRegion = "eu01" - testInstanceId = "instance" - testDisplayName = "test" - testExpiration = "1h" + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = uuid.NewString() + testExpiration = 3600 + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} ) const ( @@ -63,123 +51,30 @@ users: // 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{} { +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 + return kubeconfigMap } -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 +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, } - - // 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) + for _, mod := range mods { + mod(argValues) } -} - -// 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) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - 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) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - return nil -} - -func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { - return nil -} - -func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { - return nil + return argValues } func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - globalflags.ProjectIdFlag: testProjectId, - globalflags.RegionFlag: testRegion, - commonInstance.InstanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -187,15 +82,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st 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 { +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, @@ -207,18 +94,7 @@ func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputMode 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, - } + InstanceId: testInstanceId, } for _, mod := range mods { @@ -227,467 +103,198 @@ func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputMode return model } -func TestParseInput(t *testing.T) { - type args struct { - flags map[string]string - cmpOpts []testUtils.ValueComparisonOption +func fixtureRequest(mods ...func(request *edge.ApiGetKubeconfigByInstanceIdRequest)) edge.ApiGetKubeconfigByInstanceIdRequest { + request := testClient.DefaultAPI.GetKubeconfigByInstanceId(testCtx, testProjectId, testRegion, testInstanceId) + request = request.ExpirationSeconds(int64(testExpiration)) + for _, mod := range mods { + mod(&request) } + return request +} +func TestParseInput(t *testing.T) { tests := []struct { - name string - wantErr any - want *inputModel - args args + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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{}), - }, - }, + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - name: "with expiration", - want: fixtureByIdInputModel(func(model *inputModel) { + description: "with expiration", + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(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" - }), - }, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "1h" + }), }, { - name: "instance id missing", - wantErr: true, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, commonInstance.InstanceIdFlag) - }), - }, + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, }, { - name: "instance id empty", - wantErr: "id may not be empty", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "" - }), - }, + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - 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" - }), - }, + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, }, { - name: "instance id too short", - wantErr: "id is too short", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonInstance.InstanceIdFlag] = "id" - }), - }, + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - 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" - }), - }, + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - 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" - }), - }, + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - 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.Name()] = print.PrettyOutputFormat - }), - }, + description: "instance id invalid", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - 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" - }), - }, + description: "disable writing and invalid output format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableWritingFlag] = "true" + }), + isValid: false, }, { - name: "disable writing and valid output format", - want: fixtureByIdInputModel(func(model *inputModel) { + description: "disable writing and valid output format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableWritingFlag] = "true" + flagValues[globalflags.OutputFormatFlag.Name()] = print.YAMLOutputFormat + }), + expectedModel: fixtureInputModel(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.Name()] = 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{}, - }, + isValid: true, }, { - name: "no id or name", - wantErr: &commonErr.NoIdentifierError{}, - args: args{ - model: fixtureInputModel(false, func(model *inputModel) { - model.identifier = nil - }), - client: &mockAPIClient{}, - waiter: &mockKubeconfigWaiter{}, - }, + description: "invalid expiration format", + argValues: fixtureArgValues(), + isValid: false, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "invalid" + }), }, { - name: "instance not found error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByIdInputModel(), - client: &mockAPIClient{}, - waiter: &mockKubeconfigWaiter{waitNotFound: true}, - }, + description: "expiration too short", + argValues: fixtureArgValues(), + isValid: false, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "1s" + }), }, { - name: "get kubeconfig by id API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByIdInputModel(), - client: &mockAPIClient{}, - waiter: &mockKubeconfigWaiter{waitFails: true}, - }, + description: "expiration too long", + argValues: fixtureArgValues(), + isValid: false, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "13M" + }), }, { - name: "get kubeconfig by name API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByNameInputModel(), - client: &mockAPIClient{}, - waiter: &mockKubeconfigWaiter{waitFails: true}, - }, + description: "enable overwrite", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[overwriteFlag] = "true" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Overwrite = true + }), + isValid: 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{}, - }, + description: "disable overwrite", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[overwriteFlag] = "false" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Overwrite = false + }), + isValid: true, }, } 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) + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } func TestBuildRequest(t *testing.T) { - type args struct { - model *inputModel - client client.APIClient - } - tests := []struct { - name string - wantErr error - want *createRequestSpec - args args + description string + expectedRequest edge.ApiGetKubeconfigByInstanceIdRequest + model *inputModel }{ { - 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{}, - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, { - 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" - }), - }, + description: "expiration time", + model: fixtureInputModel(func(model *inputModel) { + model.Expiration = uint64(2592000) + }), + expectedRequest: fixtureRequest().ExpirationSeconds(int64(2592000)), }, } 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 + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("cannot create request: %v", err) } - if tt.want && got == nil { - t.Fatal("expected non-nil waiter factory") - } - if !tt.want && got != nil { - t.Fatal("expected nil waiter factory") + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, edge.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } }) } @@ -708,7 +315,7 @@ func TestOutputResult(t *testing.T) { name: "no kubeconfig", wantErr: true, args: args{ - model: fixtureByIdInputModel(), + model: fixtureInputModel(), kubeconfig: nil, }, }, @@ -716,14 +323,14 @@ func TestOutputResult(t *testing.T) { name: "kubeconfig with nil kubeconfig data", wantErr: true, args: args{ - model: fixtureByIdInputModel(), + model: fixtureInputModel(), kubeconfig: &edge.Kubeconfig{Kubeconfig: nil}, }, }, { name: "output json with disable writing", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.OutputFormat = print.JSONOutputFormat model.DisableWriting = true }), @@ -733,7 +340,7 @@ func TestOutputResult(t *testing.T) { { name: "output yaml with disable writing", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.OutputFormat = print.YAMLOutputFormat model.DisableWriting = true }), @@ -743,36 +350,7 @@ func TestOutputResult(t *testing.T) { { 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: fixtureInputModel(func(model *inputModel) { model.DisableWriting = true }), kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, @@ -781,7 +359,7 @@ func TestOutputResult(t *testing.T) { { name: "file writing enabled (default behavior)", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.AssumeYes = true }), kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, @@ -790,7 +368,7 @@ func TestOutputResult(t *testing.T) { { name: "file writing with overwrite enabled", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.Overwrite = true model.AssumeYes = true }), @@ -800,7 +378,7 @@ func TestOutputResult(t *testing.T) { { name: "file writing with switch context enabled", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.SwitchContext = true model.AssumeYes = true }), @@ -814,7 +392,7 @@ func TestOutputResult(t *testing.T) { params := testparams.NewTestParams() err := outputResult(params.Printer, tt.args.model.OutputFormat, tt.args.model, tt.args.kubeconfig) - testUtils.AssertError(t, err, tt.wantErr) + testutils.AssertError(t, err, tt.wantErr) }) } } diff --git a/internal/cmd/beta/edge/kubeconfig/kubeconfig.go b/internal/cmd/beta/edge/kubeconfig/kubeconfig.go index b44c2e1a4..ae79774fa 100644 --- a/internal/cmd/beta/edge/kubeconfig/kubeconfig.go +++ b/internal/cmd/beta/edge/kubeconfig/kubeconfig.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package kubeconfig import ( @@ -15,7 +12,7 @@ import ( func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "kubeconfig", - Short: "Provides functionality for edge kubeconfig.", + Short: "Provides functionality for Edge Cloud kubeconfig.", Long: "Provides functionality for STACKIT Edge Cloud (STEC) kubeconfig management.", Args: args.NoArgs, Run: utils.CmdHelp, diff --git a/internal/cmd/beta/edge/plans/list/list.go b/internal/cmd/beta/edge/plans/list/list.go index e8b8607fd..cc9276105 100755 --- a/internal/cmd/beta/edge/plans/list/list.go +++ b/internal/cmd/beta/edge/plans/list/list.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package list import ( @@ -8,7 +5,7 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -34,36 +31,24 @@ type inputModel struct { 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", + Short: "Lists available Edge Cloud 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`), + `Lists all Edge Cloud plans for a given project`, + `$ stackit beta edge-cloud plans 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)), + `Lists all Edge Cloud plans for a given project and limits the output to two plans`, + `$ stackit beta edge-cloud plans list --limit=2`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -82,15 +67,21 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - resp, err := run(ctx, model, apiClient) + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() if err != nil { - return err + return fmt.Errorf("get Edge Cloud plans: %w", err) } + plans := resp.GetValidPlans() - return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + // Truncate output + if model.Limit != nil && len(plans) > int(*model.Limit) { + plans = (plans)[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, plans) }, } - configureFlags(cmd) return cmd } @@ -99,8 +90,7 @@ 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) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -125,46 +115,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { 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 +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) edge.ApiListPlansProjectRequest { + return apiClient.DefaultAPI.ListPlansProject(ctx, model.ProjectId) } -// 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 diff --git a/internal/cmd/beta/edge/plans/list/list_test.go b/internal/cmd/beta/edge/plans/list/list_test.go index 5684a69bf..21e97996e 100755 --- a/internal/cmd/beta/edge/plans/list/list_test.go +++ b/internal/cmd/beta/edge/plans/list/list_test.go @@ -1,23 +1,18 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package list import ( "context" - "errors" + "strconv" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" - 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" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" - testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + testutils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -27,81 +22,12 @@ var ( testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") testProjectId = uuid.NewString() testRegion = "eu01" + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} ) -// 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) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - return nil -} -func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { - return nil -} - -func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - 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 -} +const ( + testLimit = 10 +) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ @@ -130,321 +56,153 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func TestParseInput(t *testing.T) { - type args struct { - flags map[string]string - cmpOpts []testUtils.ValueComparisonOption +func fixtureRequest(mods ...func(request *edge.ApiListPlansProjectRequest)) edge.ApiListPlansProjectRequest { + request := testClient.DefaultAPI.ListPlansProject(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) } + return request +} +func TestParseInput(t *testing.T) { tests := []struct { - name string - wantErr any - want *inputModel - args args + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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] = "" - }), - }, + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - 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" - }), - }, + description: "no values", + flagValues: map[string]string{}, + isValid: false, }, { - name: "limit is negative", - wantErr: &cliErr.FlagValidationError{}, - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[limitFlag] = "-0" - }), - }, + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, - } - - 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{}, - }, + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - 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{}, - }, + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - 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{}, - }, + description: "with limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(testLimit) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(testLimit)) + }), }, { - name: "list success with no items", - want: []edge.Plan{}, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - getPlansMock: &mockExecutable{ - executeResp: &edge.PlanList{ValidPlans: &[]edge.Plan{}}, - }, - }, - }, + description: "with limit flag == 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(0) + }), + isValid: false, }, { - name: "list API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureInputModel(), - client: &mockAPIClient{ - getPlansMock: &mockExecutable{ - executeFails: true, - }, - }, - }, + description: "with limit flag < 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(-1) + }), + isValid: false, }, } 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) + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } func TestOutputResult(t *testing.T) { type args struct { - model *inputModel - plans []edge.Plan + outputFormat string projectLabel string + plans []edge.Plan } - tests := []struct { name string - wantErr error args args + wantErr bool }{ { - 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: "empty", + args: args{}, + wantErr: false, }, { - name: "output yaml", + name: "set empty plans slice", 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", + plans: []edge.Plan{}, }, + wantErr: false, }, { - name: "output default with plans", + name: "set empty flavors in flavors slice", 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", + plans: []edge.Plan{{}}, }, + wantErr: false, }, } + params := testparams.NewTestParams() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - params := testparams.NewTestParams() - - err := outputResult(params.Printer, tt.args.model.OutputFormat, tt.args.projectLabel, tt.args.plans) - testUtils.AssertError(t, err, tt.wantErr) + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.plans); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", 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 + description string + model *inputModel + expectedRequest edge.ApiListPlansProjectRequest }{ { - 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{}, - }, - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, } 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 + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, edge.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } - 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 index d5ccb0721..1265f04fa 100644 --- a/internal/cmd/beta/edge/plans/plans.go +++ b/internal/cmd/beta/edge/plans/plans.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package plans import ( @@ -15,7 +12,7 @@ import ( func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", - Short: "Provides functionality for edge service plans.", + Short: "Provides functionality for Edge Cloud service plans.", Long: "Provides functionality for STACKIT Edge Cloud (STEC) service plan management.", Args: args.NoArgs, Run: utils.CmdHelp, diff --git a/internal/cmd/beta/edge/token/create/create.go b/internal/cmd/beta/edge/token/create/create.go index cbe7ab900..a73a71590 100755 --- a/internal/cmd/beta/edge/token/create/create.go +++ b/internal/cmd/beta/edge/token/create/create.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package create import ( @@ -8,9 +5,13 @@ import ( "fmt" "github.com/spf13/cobra" - "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" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" + "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + edgeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -19,94 +20,46 @@ import ( "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" ) +const ( + instanceIdArg = "INSTANCE_ID" + expirationFlag = "expiration" + expirationSecondsDefault = 3600 // 60 * 60 seconds = 1 hour +) + type inputModel struct { *globalflags.GlobalFlagModel - identifier *commonValidation.Identifier Expiration uint64 + InstanceId 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 - 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. +// NewCmd https://aip.stackit.cloud/aip/general/0121/ +// We have decided to eliminate the usage of display name flag +// To be the AIP compliant, and align with the standard CLI implementation, we will use the InstanceID arg func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ - Use: "create", - Short: "Creates a token for an edge instance", + Use: fmt.Sprintf("create token for %s", instanceIdArg), + Short: "Creates a token for an Edge Cloud 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), + "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(1h) seconds.", "Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units."), - Args: args.NoArgs, + Args: args.SingleArg(instanceIdArg, nil), 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)), + `Create a token for the Edge Cloud instance with instance ID "xxx".`, + `$ stackit beta edge-cloud token create xxx`), 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)), + `Create a token for the Edge Cloud instance with instance ID "xxx". The token will be valid for one day.`, + `$ stackit beta edge-cloud token create xxx --expiration 1d`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - // Parse user input (arguments and/or flags) - model, err := parseInput(params.Printer, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -121,10 +74,42 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return fmt.Errorf("async mode is not supported for token create") } + 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 + } + + instanceLabel, err := edgeUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to create a new token for Edge Cloud instance %q for project %q?", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + // Call API - resp, err := run(ctx, model, apiClient) + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() if err != nil { + return fmt.Errorf("create edge cloud instance token: %w", err) + } + + var expiration = int64(model.Expiration) // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + err = spinner.Run(params.Printer, "Creating token", func() error { + _, err = wait.TokenWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, &expiration).WaitWithContext(ctx) return err + }) + if err != nil { + return fmt.Errorf("wait for token creation: %w", err) } // Handle output to printer @@ -137,17 +122,13 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } 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...) + cmd.Flags().StringP(expirationFlag, "e", "", "Expiration time for the token, e.g. 5d. By default, the token is valid for 1h.") } // Parse user input (arguments and/or flags) -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -156,34 +137,28 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { // Generate input model based on chosen flags model := inputModel{ GlobalFlagModel: globalFlags, + InstanceId: instanceId, } - // 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) + // Parse and validate token expiration time + if expString := flags.FlagToStringPointer(p, cmd, expirationFlag); expString != nil { + expTime, err := sdkUtils.ConvertToSeconds(*expString) if err != nil { return nil, &cliErr.FlagValidationError{ - Flag: commonKubeconfig.ExpirationFlag, + Flag: expirationFlag, Details: err.Error(), } } if err := commonKubeconfig.ValidateExpiration(&expTime); err != nil { return nil, &cliErr.FlagValidationError{ - Flag: commonKubeconfig.ExpirationFlag, + Flag: expirationFlag, Details: err.Error(), } } model.Expiration = expTime } else { // Default expiration is 1 hour - defaultExp := uint64(commonKubeconfig.ExpirationSecondsDefault) + defaultExp := uint64(expirationSecondsDefault) model.Expiration = defaultExp } @@ -200,90 +175,20 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { 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) - } +func buildRequest(ctx context.Context, model *inputModel, apiClient *edge.APIClient) (edge.ApiGetTokenByInstanceIdRequest, error) { + req := apiClient.DefaultAPI.GetTokenByInstanceId(ctx, model.ProjectId, model.Region, model.InstanceId) + return req.ExpirationSeconds(int64(model.Expiration)), nil // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe } // 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 { + if 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 + tokenString := token.Token return p.OutputResult(outputFormat, token, func() error { p.Outputln(tokenString) diff --git a/internal/cmd/beta/edge/token/create/create_test.go b/internal/cmd/beta/edge/token/create/create_test.go index 8f6b69404..00588f751 100755 --- a/internal/cmd/beta/edge/token/create/create_test.go +++ b/internal/cmd/beta/edge/token/create/create_test.go @@ -1,142 +1,47 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package create import ( "context" - "errors" - "net/http" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/edge" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" - 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/testutils" ) type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testProjectId = uuid.NewString() - testRegion = "eu01" - testInstanceId = "instance" - testDisplayName = "test" - testExpiration = "1h" + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = uuid.NewString() + testExpiration = 3600 + tokenString = "test-token-string" + testClient = &edge.APIClient{DefaultAPI: &edge.DefaultAPIService{}} ) -// 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, - } +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, } - 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) + for _, mod := range mods { + mod(argValues) } -} - -// 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) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { - 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) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { - 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 + return argValues } func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - globalflags.ProjectIdFlag: testProjectId, - globalflags.RegionFlag: testRegion, - commonInstance.InstanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -144,34 +49,15 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st 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 { +func fixtureInputModel(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, - } + Expiration: uint64(testExpiration), // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + InstanceId: testInstanceId, } for _, mod := range mods { @@ -180,428 +66,155 @@ func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputMode return model } -func TestParseInput(t *testing.T) { - type args struct { - flags map[string]string - cmpOpts []testUtils.ValueComparisonOption +func fixtureRequest(mods ...func(request *edge.ApiGetTokenByInstanceIdRequest)) edge.ApiGetTokenByInstanceIdRequest { + request := testClient.DefaultAPI.GetTokenByInstanceId(testCtx, testProjectId, testRegion, testInstanceId) + request = request.ExpirationSeconds(int64(testExpiration)) + for _, mod := range mods { + mod(&request) } + return request +} +func TestParseInput(t *testing.T) { tests := []struct { - name string - wantErr any - want *inputModel - args args + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel }{ { - 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{}), - }, - }, + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), }, { - name: "with expiration", - want: fixtureByIdInputModel(func(model *inputModel) { + description: "with expiration", + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(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" - }), - }, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "1h" + }), }, { - 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" - }), - }, + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, }, { - name: "invalid expiration format", - wantErr: "invalid time string format", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonKubeconfig.ExpirationFlag] = "invalid" - }), - }, + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - name: "expiration too short", - wantErr: "expiration is too small", - args: args{ - flags: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[commonKubeconfig.ExpirationFlag] = "1s" - }), - }, + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, }, { - 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{}, - }, + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, }, { - name: "run by name success", - wantToken: true, - args: args{ - model: fixtureByNameInputModel(), - client: &mockAPIClient{}, - waiter: &mockTokenWaiter{}, - }, + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, }, { - name: "no id or name", - wantErr: &commonErr.NoIdentifierError{}, - args: args{ - model: fixtureInputModel(false, func(model *inputModel) { - model.identifier = nil - }), - client: &mockAPIClient{}, - waiter: &mockTokenWaiter{}, - }, + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, }, { - name: "instance not found error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByIdInputModel(), - client: &mockAPIClient{}, - waiter: &mockTokenWaiter{waitNotFound: true}, - }, + description: "instance id invalid", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, }, { - name: "get token by id API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByIdInputModel(), - client: &mockAPIClient{}, - waiter: &mockTokenWaiter{waitFails: true}, - }, + description: "invalid expiration format", + argValues: fixtureArgValues(), + isValid: false, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "invalid" + }), }, { - name: "get token by name API error", - wantErr: &cliErr.RequestFailedError{}, - args: args{ - model: fixtureByNameInputModel(), - client: &mockAPIClient{}, - waiter: &mockTokenWaiter{waitFails: true}, - }, + description: "expiration too short", + argValues: fixtureArgValues(), + isValid: false, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "1s" + }), }, { - 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{}, - }, + description: "expiration too long", + argValues: fixtureArgValues(), + isValid: false, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expirationFlag] = "13M" + }), }, } 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") - } + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } func TestBuildRequest(t *testing.T) { - type args struct { - model *inputModel - client client.APIClient - } - tests := []struct { - name string - wantErr error - want *createRequestSpec - args args + description string + expectedRequest edge.ApiGetTokenByInstanceIdRequest + model *inputModel }{ { - 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{}, - }, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), }, { - 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" - })}, + description: "expiration time", + model: fixtureInputModel(func(model *inputModel) { + model.Expiration = uint64(2592000) + }), + expectedRequest: fixtureRequest().ExpirationSeconds(int64(2592000)), }, } 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 + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("cannot create request: %v", err) } - if tt.want && got == nil { - t.Fatal("expected non-nil waiter factory") - } - if !tt.want && got != nil { - t.Fatal("expected nil waiter factory") + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, edge.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) } }) } @@ -613,65 +226,57 @@ func TestOutputResult(t *testing.T) { token *edge.Token } tests := []struct { - name string - wantErr any - args args + description string + wantErr any + args args }{ { - name: "default output format", + description: "default output format", args: args{ - model: fixtureByIdInputModel(), + model: fixtureInputModel(), token: &edge.Token{ - Token: func() *string { s := "test-token"; return &s }(), + Token: tokenString, }, }, }, { - name: "JSON output format", + description: "JSON output format", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.OutputFormat = print.JSONOutputFormat }), token: &edge.Token{ - Token: func() *string { s := "test-token"; return &s }(), + Token: tokenString, }, }, }, { - name: "YAML output format", + description: "YAML output format", args: args{ - model: fixtureByIdInputModel(func(model *inputModel) { + model: fixtureInputModel(func(model *inputModel) { model.OutputFormat = print.YAMLOutputFormat }), token: &edge.Token{ - Token: func() *string { s := "test-token"; return &s }(), + Token: tokenString, }, }, }, { - name: "nil token", - wantErr: true, + description: "nil token", + wantErr: true, args: args{ - model: fixtureByIdInputModel(), + model: fixtureInputModel(), 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) { + t.Run(tt.description, func(t *testing.T) { params := testparams.NewTestParams() err := outputResult(params.Printer, tt.args.model.OutputFormat, tt.args.token) - testUtils.AssertError(t, err, tt.wantErr) + testutils.AssertError(t, err, tt.wantErr) }) } } diff --git a/internal/cmd/beta/edge/token/token.go b/internal/cmd/beta/edge/token/token.go index 8fd725a72..7dd720ea0 100644 --- a/internal/cmd/beta/edge/token/token.go +++ b/internal/cmd/beta/edge/token/token.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package token import ( @@ -15,7 +12,7 @@ import ( func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "token", - Short: "Provides functionality for edge service token.", + Short: "Provides functionality for Edge Cloud service token.", Long: "Provides functionality for STACKIT Edge Cloud (STEC) token management.", Args: args.NoArgs, Run: utils.CmdHelp, diff --git a/internal/pkg/services/edge/client/client.go b/internal/pkg/services/edge/client/client.go index 77d2677ea..26b625d2b 100644 --- a/internal/pkg/services/edge/client/client.go +++ b/internal/pkg/services/edge/client/client.go @@ -1,37 +1,15 @@ -// 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-sdk-go/services/edge" - "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" -) -// APIClient is an interface that consolidates all client functionality to allow for mocking of the API client during testing. -type APIClient interface { - CreateInstance(ctx context.Context, projectId, regionId string) edge.ApiCreateInstanceRequest - DeleteInstance(ctx context.Context, projectId, regionId, instanceId string) edge.ApiDeleteInstanceRequest - DeleteInstanceByName(ctx context.Context, projectId, regionId, displayName string) edge.ApiDeleteInstanceByNameRequest - GetInstance(ctx context.Context, projectId, regionId, instanceId string) edge.ApiGetInstanceRequest - GetInstanceByName(ctx context.Context, projectId, regionId, displayName string) edge.ApiGetInstanceByNameRequest - ListInstances(ctx context.Context, projectId, regionId string) edge.ApiListInstancesRequest - UpdateInstance(ctx context.Context, projectId, regionId, instanceId string) edge.ApiUpdateInstanceRequest - UpdateInstanceByName(ctx context.Context, projectId, regionId, displayName string) edge.ApiUpdateInstanceByNameRequest - GetKubeconfigByInstanceId(ctx context.Context, projectId, regionId, instanceId string) edge.ApiGetKubeconfigByInstanceIdRequest - GetKubeconfigByInstanceName(ctx context.Context, projectId, regionId, displayName string) edge.ApiGetKubeconfigByInstanceNameRequest - GetTokenByInstanceId(ctx context.Context, projectId, regionId, instanceId string) edge.ApiGetTokenByInstanceIdRequest - GetTokenByInstanceName(ctx context.Context, projectId, regionId, displayName string) edge.ApiGetTokenByInstanceNameRequest - ListPlansProject(ctx context.Context, projectId string) edge.ApiListPlansProjectRequest -} + "github.com/spf13/viper" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" +) // 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) +func ConfigureClient(p *print.Printer, cliVersion string) (*edge.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.EdgeCustomEndpointKey), false, genericclient.CreateApiClient[*edge.APIClient](edge.NewAPIClient)) } diff --git a/internal/pkg/services/edge/common/error/error.go b/internal/pkg/services/edge/common/error/error.go deleted file mode 100755 index 2fd1433c3..000000000 --- a/internal/pkg/services/edge/common/error/error.go +++ /dev/null @@ -1,85 +0,0 @@ -// 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 deleted file mode 100755 index 1268d898a..000000000 --- a/internal/pkg/services/edge/common/error/error_test.go +++ /dev/null @@ -1,180 +0,0 @@ -// 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 deleted file mode 100644 index 6dc35c672..000000000 --- a/internal/pkg/services/edge/common/instance/instance.go +++ /dev/null @@ -1,140 +0,0 @@ -// 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 deleted file mode 100755 index 70a7dd11d..000000000 --- a/internal/pkg/services/edge/common/instance/instance_test.go +++ /dev/null @@ -1,348 +0,0 @@ -// 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 index ef0918c8b..271b2780f 100755 --- a/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go +++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package kubeconfig import ( @@ -26,29 +23,7 @@ const ( // 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 = "" + ExpirationFlag = "expiration" ) func ValidateExpiration(expiration *uint64) error { diff --git a/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go index e196052c4..bf8be4964 100755 --- a/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go +++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go @@ -1,6 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG - package kubeconfig import ( diff --git a/internal/pkg/services/edge/common/validation/input.go b/internal/pkg/services/edge/common/validation/input.go deleted file mode 100644 index c32f8a9be..000000000 --- a/internal/pkg/services/edge/common/validation/input.go +++ /dev/null @@ -1,52 +0,0 @@ -// 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 deleted file mode 100755 index ed48999f1..000000000 --- a/internal/pkg/services/edge/common/validation/input_test.go +++ /dev/null @@ -1,83 +0,0 @@ -// 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/testparams" - - 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() - - params := testparams.NewTestParams() - cmd := &cobra.Command{Use: "test"} - tt.setup(cmd) - - got, err := GetValidatedInstanceIdentifier(params.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/services/edge/utils/utils.go b/internal/pkg/services/edge/utils/utils.go new file mode 100644 index 000000000..1faff8727 --- /dev/null +++ b/internal/pkg/services/edge/utils/utils.go @@ -0,0 +1,16 @@ +package utils + +import ( + "context" + "fmt" + + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" +) + +func GetInstanceName(ctx context.Context, apiClient edge.DefaultAPI, projectId, instanceId, region string) (string, error) { + resp, err := apiClient.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + return "", fmt.Errorf("get Edge Cloud instance: %w", err) + } + return resp.DisplayName, nil +} diff --git a/internal/pkg/services/edge/utils/utils_test.go b/internal/pkg/services/edge/utils/utils_test.go new file mode 100644 index 000000000..13e12800c --- /dev/null +++ b/internal/pkg/services/edge/utils/utils_test.go @@ -0,0 +1,86 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/uuid" + edge "github.com/stackitcloud/stackit-sdk-go/services/edge/v1beta1api" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testRegion = "eu01" +) + +type mockSettings struct { + getInstanceFails bool + getInstanceResp *edge.Instance +} + +func newApiMock(s *mockSettings) edge.DefaultAPI { + return &edge.DefaultAPIServiceMock{ + GetInstanceExecuteMock: utils.Ptr(func(_ edge.ApiGetInstanceRequest) (*edge.Instance, error) { + if s.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return s.getInstanceResp, nil + }), + } +} + +func TestGetInstanceName(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *edge.Instance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &edge.Instance{ + DisplayName: testInstanceName, + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + settings := &mockSettings{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceName(context.Background(), newApiMock(settings), testProjectId, testInstanceId, testRegion) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} From 7d2c79bfa0055a9b2972a77418559cb21872cab4 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 27 Jul 2026 14:59:50 +0200 Subject: [PATCH 2/2] chore(edge): fix typos relates to STACKITCLI-357 --- internal/cmd/git/instance/delete/delete.go | 2 +- internal/cmd/mongodbflex/instance/create/create.go | 2 +- internal/cmd/ske/kubeconfig/create/create.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/git/instance/delete/delete.go b/internal/cmd/git/instance/delete/delete.go index ed8e4e4d5..2a59f4f55 100644 --- a/internal/cmd/git/instance/delete/delete.go +++ b/internal/cmd/git/instance/delete/delete.go @@ -60,7 +60,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { instanceName, err := gitUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId) if err != nil { - params.Printer.Debug(print.ErrorLevel, "get stackit git intance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get stackit git instance name: %v", err) instanceName = model.InstanceId } else if instanceName == "" { instanceName = model.InstanceId diff --git a/internal/cmd/mongodbflex/instance/create/create.go b/internal/cmd/mongodbflex/instance/create/create.go index 9dcf3b23e..8d0237887 100644 --- a/internal/cmd/mongodbflex/instance/create/create.go +++ b/internal/cmd/mongodbflex/instance/create/create.go @@ -247,7 +247,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC replicas, err := mongodbflexUtils.GetInstanceReplicas(*model.Type) if err != nil { - return req, fmt.Errorf("get MongoDB Flex intance type: %w", err) + return req, fmt.Errorf("get MongoDB Flex instance type: %w", err) } req = req.CreateInstancePayload(mongodbflex.CreateInstancePayload{ diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go index 96093f901..287ede7fd 100644 --- a/internal/cmd/ske/kubeconfig/create/create.go +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -102,7 +102,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.Overwrite { prompt = fmt.Sprintf("Are you sure you want to create a kubeconfig for SKE cluster %q? This will OVERWRITE your current kubeconfig file, if it exists.", model.ClusterName) } else { - prompt = fmt.Sprintf("Are you sure you want to update your kubeconfig for SKE cluster %q? This will update your kubeconfig file. \nIf the kubeconfig file does not exists, it will create a new one.", model.ClusterName) + prompt = fmt.Sprintf("Are you sure you want to update your kubeconfig for SKE cluster %q? This will update your kubeconfig file. \nIf the kubeconfig file does not exist, it will create a new one.", model.ClusterName) } err = params.Printer.PromptForConfirmation(prompt) if err != nil {