diff --git a/docs/stackit_postgresflex_instance_create.md b/docs/stackit_postgresflex_instance_create.md index 51b1f3d5c..4df9a23c6 100644 --- a/docs/stackit_postgresflex_instance_create.md +++ b/docs/stackit_postgresflex_instance_create.md @@ -14,13 +14,13 @@ stackit postgresflex instance create [flags] ``` Create a PostgreSQL Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by CPU and RAM. Other parameters are set to default values - $ stackit postgresflex instance create --name my-instance --cpu 1 --ram 4 --acl 0.0.0.0/0 + $ stackit postgresflex instance create --name my-instance --cpu 2 --ram 4 --acl 0.0.0.0/0 Create a PostgreSQL Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by ID. Other parameters are set to default values $ stackit postgresflex instance create --name my-instance --flavor-id xxx --acl 0.0.0.0/0 Create a PostgreSQL Flex instance with name "my-instance", allow access to a specific range of IP addresses, specify flavor by CPU and RAM and set storage size to 20 GB. Other parameters are set to default values - $ stackit postgresflex instance create --name my-instance --cpu 1 --ram 4 --acl 1.2.3.0/24 --storage-size 20 + $ stackit postgresflex instance create --name my-instance --cpu 2 --ram 4 --acl 1.2.3.0/24 --storage-size 20 ``` ### Options diff --git a/docs/stackit_postgresflex_instance_delete.md b/docs/stackit_postgresflex_instance_delete.md index 5a7a27b49..b4b6bb94d 100644 --- a/docs/stackit_postgresflex_instance_delete.md +++ b/docs/stackit_postgresflex_instance_delete.md @@ -5,6 +5,8 @@ Deletes a PostgreSQL Flex instance ### Synopsis Deletes a PostgreSQL Flex instance. +By default, instances will be kept in a delayed deleted state for 7 days before being permanently deleted. +Use the --force flag to force the immediate deletion of a delayed deleted instance. ``` stackit postgresflex instance delete INSTANCE_ID [flags] @@ -15,12 +17,16 @@ stackit postgresflex instance delete INSTANCE_ID [flags] ``` Delete a PostgreSQL Flex instance with ID "xxx" $ stackit postgresflex instance delete xxx + + Force the deletion of a delayed deleted PostgreSQL Flex instance with ID "xxx" + $ stackit postgresflex instance delete xxx --force ``` ### Options ``` - -h, --help Help for "stackit postgresflex instance delete" + -f, --force Force deletion of a delayed deleted instance + -h, --help Help for "stackit postgresflex instance delete" ``` ### Options inherited from parent commands diff --git a/docs/stackit_project_describe.md b/docs/stackit_project_describe.md index d2e1963c2..807f653e3 100644 --- a/docs/stackit_project_describe.md +++ b/docs/stackit_project_describe.md @@ -17,7 +17,7 @@ stackit project describe [flags] $ stackit project describe Get the details of a STACKIT project by explicitly providing the project ID - $ stackit project describe --project-id xxx + $ stackit project describe xxx Get the details of the configured STACKIT project, including details of the parent resources $ stackit project describe --include-parents diff --git a/docs/stackit_project_list.md b/docs/stackit_project_list.md index 4582bce84..fb84eee4f 100644 --- a/docs/stackit_project_list.md +++ b/docs/stackit_project_list.md @@ -13,6 +13,9 @@ stackit project list [flags] ### Examples ``` + List all STACKIT projects that the authenticated user or service account is a member of + $ stackit project list + List all STACKIT projects that are children of a specific parent $ stackit project list --parent-id xxx diff --git a/docs/stackit_project_member_list.md b/docs/stackit_project_member_list.md index 171f20d67..811b9f9c4 100644 --- a/docs/stackit_project_member_list.md +++ b/docs/stackit_project_member_list.md @@ -14,13 +14,13 @@ stackit project member list [flags] ``` List all members of a project - $ stackit project role list --project-id xxx + $ stackit project member list --project-id xxx List all members of a project, sorted by role - $ stackit project role list --project-id xxx --sort-by role + $ stackit project member list --project-id xxx --sort-by role List up to 10 members of a project - $ stackit project role list --project-id xxx --limit 10 + $ stackit project member list --project-id xxx --limit 10 ``` ### Options diff --git a/docs/stackit_ske.md b/docs/stackit_ske.md index f04657388..746ad86bc 100644 --- a/docs/stackit_ske.md +++ b/docs/stackit_ske.md @@ -34,5 +34,6 @@ stackit ske [flags] * [stackit ske describe](./stackit_ske_describe.md) - Shows overall details regarding SKE * [stackit ske disable](./stackit_ske_disable.md) - Disables SKE for a project * [stackit ske enable](./stackit_ske_enable.md) - Enables SKE for a project +* [stackit ske kubeconfig](./stackit_ske_kubeconfig.md) - Provides functionality for SKE kubeconfig * [stackit ske options](./stackit_ske_options.md) - Lists SKE provider options diff --git a/docs/stackit_ske_credentials.md b/docs/stackit_ske_credentials.md index 414a50a14..41920d19a 100644 --- a/docs/stackit_ske_credentials.md +++ b/docs/stackit_ske_credentials.md @@ -29,6 +29,6 @@ stackit ske credentials [flags] ### SEE ALSO * [stackit ske](./stackit_ske.md) - Provides functionality for SKE -* [stackit ske credentials describe](./stackit_ske_credentials_describe.md) - Shows details of the credentials associated to a SKE cluster -* [stackit ske credentials rotate](./stackit_ske_credentials_rotate.md) - Rotates credentials associated to a SKE cluster +* [stackit ske credentials complete-rotation](./stackit_ske_credentials_complete-rotation.md) - Completes the rotation of the credentials associated to a SKE cluster +* [stackit ske credentials start-rotation](./stackit_ske_credentials_start-rotation.md) - Starts the rotation of the credentials associated to a SKE cluster diff --git a/docs/stackit_ske_credentials_complete-rotation.md b/docs/stackit_ske_credentials_complete-rotation.md new file mode 100644 index 000000000..583232472 --- /dev/null +++ b/docs/stackit_ske_credentials_complete-rotation.md @@ -0,0 +1,54 @@ +## stackit ske credentials complete-rotation + +Completes the rotation of the credentials associated to a SKE cluster + +### Synopsis + +Completes the rotation of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. + +This is step 2 of a 2-step process to rotate all SKE cluster credentials. Tasks accomplished in this phase include: + - The old certification authority will be dropped from the package. + - The old signing key for the service account will be dropped from the bundle. +To ensure continued access to the Kubernetes cluster, please update your kubeconfig with the new credentials: + $ stackit ske kubeconfig create my-cluster + +If you haven't, please start the process by running: + $ stackit ske credentials start-rotation my-cluster +For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html + +``` +stackit ske credentials complete-rotation CLUSTER_NAME [flags] +``` + +### Examples + +``` + Complete the rotation of the credentials associated to the SKE cluster with name "my-cluster" + $ stackit ske credentials complete-rotation my-cluster + + Flow of the 2-step process to rotate all SKE cluster credentials, including generating a new kubeconfig file + $ stackit ske credentials start-rotation my-cluster + $ stackit ske kubeconfig create my-cluster + $ stackit ske credentials complete-rotation my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske credentials complete-rotation" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials + diff --git a/docs/stackit_ske_credentials_describe.md b/docs/stackit_ske_credentials_describe.md deleted file mode 100644 index f5fe032d3..000000000 --- a/docs/stackit_ske_credentials_describe.md +++ /dev/null @@ -1,42 +0,0 @@ -## stackit ske credentials describe - -Shows details of the credentials associated to a SKE cluster - -### Synopsis - -Shows details of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster - -``` -stackit ske credentials describe CLUSTER_NAME [flags] -``` - -### Examples - -``` - Get details of the credentials associated to the SKE cluster with name "my-cluster" - $ stackit ske credentials describe my-cluster - - Get details of the credentials associated to the SKE cluster with name "my-cluster" in a table format - $ stackit ske credentials describe my-cluster --output-format pretty -``` - -### Options - -``` - -h, --help Help for "stackit ske credentials describe" -``` - -### Options inherited from parent commands - -``` - -y, --assume-yes If set, skips all confirmation prompts - --async If set, runs the command asynchronously - -o, --output-format string Output format, one of ["json" "pretty"] - -p, --project-id string Project ID - --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") -``` - -### SEE ALSO - -* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials - diff --git a/docs/stackit_ske_credentials_rotate.md b/docs/stackit_ske_credentials_rotate.md deleted file mode 100644 index f2111b251..000000000 --- a/docs/stackit_ske_credentials_rotate.md +++ /dev/null @@ -1,39 +0,0 @@ -## stackit ske credentials rotate - -Rotates credentials associated to a SKE cluster - -### Synopsis - -Rotates credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. The old credentials will be invalid after the operation. - -``` -stackit ske credentials rotate CLUSTER_NAME [flags] -``` - -### Examples - -``` - Rotate credentials associated to the SKE cluster with name "my-cluster" - $ stackit ske credentials rotate my-cluster -``` - -### Options - -``` - -h, --help Help for "stackit ske credentials rotate" -``` - -### Options inherited from parent commands - -``` - -y, --assume-yes If set, skips all confirmation prompts - --async If set, runs the command asynchronously - -o, --output-format string Output format, one of ["json" "pretty"] - -p, --project-id string Project ID - --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") -``` - -### SEE ALSO - -* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials - diff --git a/docs/stackit_ske_credentials_start-rotation.md b/docs/stackit_ske_credentials_start-rotation.md new file mode 100644 index 000000000..2ae5f6837 --- /dev/null +++ b/docs/stackit_ske_credentials_start-rotation.md @@ -0,0 +1,58 @@ +## stackit ske credentials start-rotation + +Starts the rotation of the credentials associated to a SKE cluster + +### Synopsis + +Starts the rotation of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. + +This is step 1 of a 2-step process to rotate all SKE cluster credentials. Tasks accomplished in this phase include: + - Rolling recreation of all worker nodes + - A new Certificate Authority (CA) will be established and incorporated into the existing CA bundle. + - A new etcd encryption key is generated and added to the Certificate Authority (CA) bundle. + - A new signing key will be generated for the service account and added to the Certificate Authority (CA) bundle. + - The kube-apiserver will rewrite all secrets in the cluster, encrypting them with the new encryption key. +The old CA, encryption key and signing key will be retained until the rotation is completed. + +After completing the rotation of credentials, you can generate a new kubeconfig file by running: + $ stackit ske kubeconfig create my-cluster +Complete the rotation by running: + $ stackit ske credentials complete-rotation my-cluster +For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html + +``` +stackit ske credentials start-rotation CLUSTER_NAME [flags] +``` + +### Examples + +``` + Start the rotation of the credentials associated to the SKE cluster with name "my-cluster" + $ stackit ske credentials start-rotation my-cluster + + Flow of the 2-step process to rotate all SKE cluster credentials, including generating a new kubeconfig file + $ stackit ske credentials start-rotation my-cluster + $ stackit ske kubeconfig create my-cluster + $ stackit ske credentials complete-rotation my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske credentials start-rotation" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials + diff --git a/docs/stackit_ske_kubeconfig.md b/docs/stackit_ske_kubeconfig.md new file mode 100644 index 000000000..d42c1eaf7 --- /dev/null +++ b/docs/stackit_ske_kubeconfig.md @@ -0,0 +1,33 @@ +## stackit ske kubeconfig + +Provides functionality for SKE kubeconfig + +### Synopsis + +Provides functionality for STACKIT Kubernetes Engine (SKE) kubeconfig. + +``` +stackit ske kubeconfig [flags] +``` + +### Options + +``` + -h, --help Help for "stackit ske kubeconfig" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE +* [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates a kubeconfig for an SKE cluster + diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md new file mode 100644 index 000000000..bbd33fa1a --- /dev/null +++ b/docs/stackit_ske_kubeconfig_create.md @@ -0,0 +1,55 @@ +## stackit ske kubeconfig create + +Creates a kubeconfig for an SKE cluster + +### Synopsis + +Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster. + +By default the kubeconfig is created in the .kube folder, in the user's home directory. The kubeconfig file will be overwritten if it already exists. +You can override this behavior by specifying a custom filepath with the --filepath flag. +An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 1h. +Note that the format is , e.g. 30d for 30 days and you can't combine units. + +``` +stackit ske kubeconfig create CLUSTER_NAME [flags] +``` + +### Examples + +``` + Create a kubeconfig for the SKE cluster with name "my-cluster" + $ stackit ske kubeconfig create my-cluster + + Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days + $ stackit ske kubeconfig create my-cluster --expiration 30d + + Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months + $ stackit ske kubeconfig create my-cluster --expiration 2M + + Create a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath + $ stackit ske kubeconfig create my-cluster --filepath /path/to/config +``` + +### Options + +``` + -e, --expiration string Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h + --filepath string Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory. + -h, --help Help for "stackit ske kubeconfig create" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske kubeconfig](./stackit_ske_kubeconfig.md) - Provides functionality for SKE kubeconfig + diff --git a/go.mod b/go.mod index 0733646ca..ed6dc1ea9 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,11 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.4 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.11.1 github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.10.1 - github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.10.0 + github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.12.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.7.7 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.6.0 github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.6 - github.com/stackitcloud/stackit-sdk-go/services/ske v0.10.1 + github.com/stackitcloud/stackit-sdk-go/services/ske v0.11.0 github.com/zalando/go-keyring v0.2.4 golang.org/x/mod v0.16.0 golang.org/x/oauth2 v0.18.0 @@ -59,7 +59,7 @@ require ( golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/sys v0.16.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.32.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2720421f0..549215f25 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,8 @@ github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.8.6 h1:+mcoBKs6 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.8.6/go.mod h1:W9BML8bqZb2dOZe1K+M+qBBs8/QNirr3jA0xxy9tNRY= github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.10.1 h1:LKic8dXtXKsRst2+wY9dNjjkMyJ05QIDpOJuRmVb410= github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.10.1/go.mod h1:g1o1bmqtTliy9UkFlRV/6bn6GQk+hkvnny3UjMI69S0= -github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.10.0 h1:+pGOyx15jkU6PcS6wqjBd7gxNIwB72vofzJ1rhGEt3M= -github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.10.0/go.mod h1:qzbM0fmR9YiUzu3S+XnbOvvjIGzIV+Dz59zEizg6xvw= +github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.12.0 h1:W2WSYUyhKaHQ+BZfmyRw9PKv5q7ihGRyNhNgIlyM+Y8= +github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.12.0/go.mod h1:P0YyvgwIsVKJijdWGVJVOp/ac7PVX99Oj+dr4v1zECc= github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.10.0 h1:Fle394socpyf662g3jMrtZpZaWVgBMBIEFnh4fnGock= github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.10.0/go.mod h1:JvqOSrTCiynS0x6Y9OsK54yvdB6AtIWLwXDEjoCkAIg= github.com/stackitcloud/stackit-sdk-go/services/redis v0.10.1 h1:/tRad17HUcGRm448l8XyX6uhnnHVfj3VdUQquIwNq2Q= @@ -103,8 +103,8 @@ github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.6.0 h1:VC7VWad github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.6.0/go.mod h1:KRoLXZdH8yuO6FBu2Grl5VGqW9arH03qYAC0P6H8h9o= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.6 h1:3kkNh2kHi55w9dgh0MC1Zbn8fDpYxcXl3tvYjH8t9xo= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.6/go.mod h1:OOciROyQxPOYLo8OM/DE5ESH11+DvAyRt6wg7R+HVkg= -github.com/stackitcloud/stackit-sdk-go/services/ske v0.10.1 h1:MZABtJ8HFOKG3KCCv5duibxBSAU1zTFAO0V9bso3N9M= -github.com/stackitcloud/stackit-sdk-go/services/ske v0.10.1/go.mod h1:7M7bsVHN0REuwoZRYz5nK2yBwsMJcHTsVFHlG83QP2A= +github.com/stackitcloud/stackit-sdk-go/services/ske v0.11.0 h1:BJ1Op7f3KJPNROkEXzqAREl55JCqyIAyQJ+Gfu4LYCM= +github.com/stackitcloud/stackit-sdk-go/services/ske v0.11.0/go.mod h1:yFLjTx58pjHCp0KZTaqHlW9Qk60CY5HpnBWR/zztv8Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -158,8 +158,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/dns/record-set/describe/describe.go b/internal/cmd/dns/record-set/describe/describe.go index 97bb57b4d..d4dda2b99 100644 --- a/internal/cmd/dns/record-set/describe/describe.go +++ b/internal/cmd/dns/record-set/describe/describe.go @@ -104,12 +104,11 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClie func outputResult(p *print.Printer, outputFormat string, recordSet *dns.RecordSet) error { switch outputFormat { case globalflags.PrettyOutputFormat: - records := *recordSet.Records - recordsData := []string{} - for i := range records { - recordsData = append(recordsData, *records[i].Content) + recordsData := make([]string, 0, len(*recordSet.Records)) + for _, r := range *recordSet.Records { + recordsData = append(recordsData, *r.Content) } - recordsDataJoin := strings.Join(recordsData, ",") + recordsDataJoin := strings.Join(recordsData, ", ") table := tables.NewTable() table.AddRow("ID", *recordSet.Id) diff --git a/internal/cmd/dns/record-set/list/list.go b/internal/cmd/dns/record-set/list/list.go index 16b86cfbc..0f75ed294 100644 --- a/internal/cmd/dns/record-set/list/list.go +++ b/internal/cmd/dns/record-set/list/list.go @@ -233,10 +233,15 @@ func outputResult(p *print.Printer, outputFormat string, recordSets []dns.Record return nil default: table := tables.NewTable() - table.SetHeader("ID", "NAME", "STATUS", "TTL", "TYPE") + table.SetHeader("ID", "NAME", "STATUS", "TTL", "TYPE", "RECORD DATA") for i := range recordSets { rs := recordSets[i] - table.AddRow(*rs.Id, *rs.Name, *rs.State, *rs.Ttl, *rs.Type) + recordData := make([]string, 0, len(*rs.Records)) + for _, r := range *rs.Records { + recordData = append(recordData, *r.Content) + } + recordDataJoin := strings.Join(recordData, ", ") + table.AddRow(*rs.Id, *rs.Name, *rs.State, *rs.Ttl, *rs.Type, recordDataJoin) } err := table.Display(p) if err != nil { diff --git a/internal/cmd/logme/plans/plans.go b/internal/cmd/logme/plans/plans.go index 60d991ad6..a88f7c7a3 100644 --- a/internal/cmd/logme/plans/plans.go +++ b/internal/cmd/logme/plans/plans.go @@ -128,16 +128,16 @@ func outputResult(p *print.Printer, outputFormat string, plans []logme.Offering) return nil default: table := tables.NewTable() - table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { o := plans[i] for j := range *o.Plans { p := (*o.Plans)[j] - table.AddRow(*o.Name, *p.Id, *p.Name, *p.Description) + table.AddRow(*o.Name, *o.Version, *p.Id, *p.Name, *p.Description) } table.AddSeparator() } - table.EnableAutoMergeOnColumns(1) + table.EnableAutoMergeOnColumns(1, 2) err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) diff --git a/internal/cmd/mariadb/plans/plans.go b/internal/cmd/mariadb/plans/plans.go index 5f1c9c879..69bde681b 100644 --- a/internal/cmd/mariadb/plans/plans.go +++ b/internal/cmd/mariadb/plans/plans.go @@ -128,16 +128,16 @@ func outputResult(p *print.Printer, outputFormat string, plans []mariadb.Offerin return nil default: table := tables.NewTable() - table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { o := plans[i] for j := range *o.Plans { plan := (*o.Plans)[j] - table.AddRow(*o.Name, *plan.Id, *plan.Name, *plan.Description) + table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description) } table.AddSeparator() } - table.EnableAutoMergeOnColumns(1) + table.EnableAutoMergeOnColumns(1, 2) err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) diff --git a/internal/cmd/opensearch/plans/plans.go b/internal/cmd/opensearch/plans/plans.go index 02a4f61ad..9bbc8ac25 100644 --- a/internal/cmd/opensearch/plans/plans.go +++ b/internal/cmd/opensearch/plans/plans.go @@ -128,16 +128,16 @@ func outputResult(p *print.Printer, outputFormat string, plans []opensearch.Offe return nil default: table := tables.NewTable() - table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { o := plans[i] for j := range *o.Plans { plan := (*o.Plans)[j] - table.AddRow(*o.Name, *plan.Id, *plan.Name, *plan.Description) + table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description) } table.AddSeparator() } - table.EnableAutoMergeOnColumns(1) + table.EnableAutoMergeOnColumns(1, 2) err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) diff --git a/internal/cmd/postgresflex/instance/create/create.go b/internal/cmd/postgresflex/instance/create/create.go index 6a53e0eb5..5b3df3288 100644 --- a/internal/cmd/postgresflex/instance/create/create.go +++ b/internal/cmd/postgresflex/instance/create/create.go @@ -64,13 +64,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Example: examples.Build( examples.NewExample( `Create a PostgreSQL Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by CPU and RAM. Other parameters are set to default values`, - `$ stackit postgresflex instance create --name my-instance --cpu 1 --ram 4 --acl 0.0.0.0/0`), + `$ stackit postgresflex instance create --name my-instance --cpu 2 --ram 4 --acl 0.0.0.0/0`), examples.NewExample( `Create a PostgreSQL Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by ID. Other parameters are set to default values`, `$ stackit postgresflex instance create --name my-instance --flavor-id xxx --acl 0.0.0.0/0`), examples.NewExample( `Create a PostgreSQL Flex instance with name "my-instance", allow access to a specific range of IP addresses, specify flavor by CPU and RAM and set storage size to 20 GB. Other parameters are set to default values`, - `$ stackit postgresflex instance create --name my-instance --cpu 1 --ram 4 --acl 1.2.3.0/24 --storage-size 20`), + `$ stackit postgresflex instance create --name my-instance --cpu 2 --ram 4 --acl 1.2.3.0/24 --storage-size 20`), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/postgresflex/instance/delete/delete.go b/internal/cmd/postgresflex/instance/delete/delete.go index 889a76da9..ce91cb1e7 100644 --- a/internal/cmd/postgresflex/instance/delete/delete.go +++ b/internal/cmd/postgresflex/instance/delete/delete.go @@ -7,6 +7,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" @@ -21,23 +22,33 @@ import ( const ( instanceIdArg = "INSTANCE_ID" + + forceDeleteFlag = "force" ) type inputModel struct { *globalflags.GlobalFlagModel - InstanceId string + InstanceId string + ForceDelete bool } func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a PostgreSQL Flex instance", - Long: "Deletes a PostgreSQL Flex instance.", - Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Long: fmt.Sprintf("%s\n%s\n%s", + "Deletes a PostgreSQL Flex instance.", + "By default, instances will be kept in a delayed deleted state for 7 days before being permanently deleted.", + "Use the --force flag to force the immediate deletion of a delayed deleted instance.", + ), + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( `Delete a PostgreSQL Flex instance with ID "xxx"`, "$ stackit postgresflex instance delete xxx"), + examples.NewExample( + `Force the deletion of a delayed deleted PostgreSQL Flex instance with ID "xxx"`, + "$ stackit postgresflex instance delete xxx --force"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -65,35 +76,73 @@ func NewCmd(p *print.Printer) *cobra.Command { } } - // Call API - req := buildRequest(ctx, model, apiClient) - err = req.Execute() + toDelete, toForceDelete, err := getNextOperations(ctx, model, apiClient) if err != nil { - return fmt.Errorf("delete PostgreSQL Flex instance: %w", err) + return err + } + + if toDelete { + // Call API + delReq := buildDeleteRequest(ctx, model, apiClient) + err = delReq.Execute() + if err != nil { + return fmt.Errorf("delete PostgreSQL Flex instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Deleting instance") + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for PostgreSQL Flex instance deletion: %w", err) + } + s.Stop() + } } - // Wait for async operation, if async mode not enabled - if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if toForceDelete { + // Call API + forceDelReq := buildForceDeleteRequest(ctx, model, apiClient) + err = forceDelReq.Execute() if err != nil { - return fmt.Errorf("wait for PostgreSQL Flex instance deletion: %w", err) + return fmt.Errorf("force delete PostgreSQL Flex instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Forcing deletion of instance") + _, err = wait.ForceDeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for PostgreSQL Flex instance force deletion: %w", err) + } + s.Stop() } - s.Stop() } operationState := "Deleted" + if toForceDelete { + operationState = "Forcefully deleted" + } if model.Async { operationState = "Triggered deletion of" + if toForceDelete { + operationState = "Triggered forced deletion of" + } } p.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } + configureFlags(cmd) return cmd } +func configureFlags(cmd *cobra.Command) { + cmd.Flags().BoolP(forceDeleteFlag, "f", false, "Force deletion of a delayed deleted instance") +} + func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { instanceId := inputArgs[0] @@ -105,10 +154,39 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { return &inputModel{ GlobalFlagModel: globalFlags, InstanceId: instanceId, + ForceDelete: flags.FlagToBoolValue(cmd, forceDeleteFlag), }, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiDeleteInstanceRequest { +func buildDeleteRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiDeleteInstanceRequest { req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) return req } + +func buildForceDeleteRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiForceDeleteInstanceRequest { + req := apiClient.ForceDeleteInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +type PostgreSQLFlexClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error) + ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error) + GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*postgresflex.GetUserResponse, error) +} + +func getNextOperations(ctx context.Context, model *inputModel, apiClient PostgreSQLFlexClient) (toDelete, toForceDelete bool, err error) { + instanceStatus, err := postgresflexUtils.GetInstanceStatus(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + return false, false, fmt.Errorf("get PostgreSQL Flex instance status: %w", err) + } + + if instanceStatus == wait.InstanceStateDeleted { + if !model.ForceDelete { + return false, false, fmt.Errorf("instance is already deleted, use --force to force the deletion of a delayed deleted instance") + } + + return false, model.ForceDelete, nil + } + + return true, model.ForceDelete, nil +} diff --git a/internal/cmd/postgresflex/instance/delete/delete_test.go b/internal/cmd/postgresflex/instance/delete/delete_test.go index c1dec961a..60b1fd00a 100644 --- a/internal/cmd/postgresflex/instance/delete/delete_test.go +++ b/internal/cmd/postgresflex/instance/delete/delete_test.go @@ -2,14 +2,17 @@ package delete import ( "context" + "fmt" "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/wait" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -21,6 +24,27 @@ var testClient = &postgresflex.APIClient{} var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() +type postgresFlexClientMocked struct { + getInstanceFails bool + getInstanceResp *postgresflex.InstanceResponse +} + +func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*postgresflex.InstanceResponse, error) { + if c.getInstanceFails { + return nil, fmt.Errorf("get instance failed") + } + return c.getInstanceResp, nil +} + +func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*postgresflex.ListVersionsResponse, error) { + // Not used in testing + return nil, nil +} +func (c *postgresFlexClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*postgresflex.GetUserResponse, error) { + // Not used in testing + return nil, nil +} + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testInstanceId, @@ -55,7 +79,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func fixtureRequest(mods ...func(request *postgresflex.ApiDeleteInstanceRequest)) postgresflex.ApiDeleteInstanceRequest { +func fixtureDeleteRequest(mods ...func(request *postgresflex.ApiDeleteInstanceRequest)) postgresflex.ApiDeleteInstanceRequest { request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId) for _, mod := range mods { mod(&request) @@ -63,6 +87,14 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiDeleteInstanceRequest) return request } +func fixtureForceDeleteRequest(mods ...func(request *postgresflex.ApiForceDeleteInstanceRequest)) postgresflex.ApiForceDeleteInstanceRequest { + request := testClient.ForceDeleteInstance(testCtx, testProjectId, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + func TestParseInput(t *testing.T) { tests := []struct { description string @@ -187,7 +219,7 @@ func TestParseInput(t *testing.T) { } } -func TestBuildRequest(t *testing.T) { +func TestBuildDeleteRequest(t *testing.T) { tests := []struct { description string model *inputModel @@ -196,13 +228,41 @@ func TestBuildRequest(t *testing.T) { { description: "base", model: fixtureInputModel(), - expectedRequest: fixtureRequest(), + expectedRequest: fixtureDeleteRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildDeleteRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildForceDeleteRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest postgresflex.ApiForceDeleteInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureForceDeleteRequest(), }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, tt.model, testClient) + request := buildForceDeleteRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), @@ -214,3 +274,97 @@ func TestBuildRequest(t *testing.T) { }) } } + +func TestCheckIfInstanceIsDeleted(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedToDelete bool + expectedToForceDelete bool + getInstanceResponse *postgresflex.InstanceResponse + getInstanceFails bool + isValid bool + }{ + { + description: "delete instance state Ready", + model: fixtureInputModel(), + expectedToDelete: true, + expectedToForceDelete: false, + getInstanceResponse: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Status: utils.Ptr(wait.InstanceStateSuccess), + }, + }, + isValid: true, + }, + { + description: "force delete instance state Ready", + model: fixtureInputModel(func(model *inputModel) { + model.ForceDelete = true + }), + expectedToDelete: true, + expectedToForceDelete: true, + getInstanceResponse: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Status: utils.Ptr(wait.InstanceStateSuccess), + }, + }, + isValid: true, + }, + { + description: "force delete instance state Deleted", + model: fixtureInputModel(func(model *inputModel) { + model.ForceDelete = true + }), + expectedToDelete: false, + expectedToForceDelete: true, + getInstanceResponse: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Status: utils.Ptr(wait.InstanceStateDeleted), + }, + }, + isValid: true, + }, + { + description: "delete instance state Deleted", + model: fixtureInputModel(), + getInstanceResponse: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Status: utils.Ptr(wait.InstanceStateDeleted), + }, + }, + isValid: false, + }, + { + description: "delete instance get instance fails", + model: fixtureInputModel(), + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &postgresFlexClientMocked{ + getInstanceResp: tt.getInstanceResponse, + getInstanceFails: tt.getInstanceFails, + } + + toDelete, toForceDelete, err := getNextOperations(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error checking if instance is deleted: %v", err) + } + + if toDelete != tt.expectedToDelete { + t.Fatalf("toDelete does not match: got %v, expected %v", toDelete, tt.expectedToDelete) + } + + if toForceDelete != tt.expectedToForceDelete { + t.Fatalf("toForceDelete does not match: got %v, expected %v", toForceDelete, tt.expectedToForceDelete) + } + }) + } +} diff --git a/internal/cmd/project/describe/describe.go b/internal/cmd/project/describe/describe.go index 87b920d78..02f5428d6 100644 --- a/internal/cmd/project/describe/describe.go +++ b/internal/cmd/project/describe/describe.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" @@ -20,10 +20,13 @@ import ( const ( includeParentsFlag = "include-parents" + + projectIdArg = "PROJECT_ID" ) type inputModel struct { *globalflags.GlobalFlagModel + ArgProjectId string IncludeParents bool } @@ -32,21 +35,21 @@ func NewCmd(p *print.Printer) *cobra.Command { Use: "describe", Short: "Shows details of a STACKIT project", Long: "Shows details of a STACKIT project.", - Args: args.NoArgs, + Args: args.SingleOptionalArg(projectIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( `Get the details of the configured STACKIT project`, "$ stackit project describe"), examples.NewExample( `Get the details of a STACKIT project by explicitly providing the project ID`, - "$ stackit project describe --project-id xxx"), + "$ stackit project describe xxx"), examples.NewExample( `Get the details of the configured STACKIT project, including details of the parent resources`, "$ stackit project describe --include-parents"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(cmd) + model, err := parseInput(cmd, args) if err != nil { return err } @@ -75,20 +78,30 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(includeParentsFlag, false, "When true, the details of the parent resources will be included in the output") } -func parseInput(cmd *cobra.Command) (*inputModel, error) { +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + var projectId string + if len(inputArgs) > 0 { + projectId = inputArgs[0] + } + globalFlags := globalflags.Parse(cmd) - if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + if globalFlags.ProjectId == "" && projectId == "" { + return nil, fmt.Errorf("Project ID needs to be provided either as an argument or as a flag") + } + + if projectId == "" { + projectId = globalFlags.ProjectId } return &inputModel{ GlobalFlagModel: globalFlags, + ArgProjectId: projectId, IncludeParents: flags.FlagToBoolValue(cmd, includeParentsFlag), }, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiGetProjectRequest { - req := apiClient.GetProject(ctx, model.ProjectId) + req := apiClient.GetProject(ctx, model.ArgProjectId) req.IncludeParents(model.IncludeParents) return req } diff --git a/internal/cmd/project/describe/describe_test.go b/internal/cmd/project/describe/describe_test.go index 61979b130..cd60acf0a 100644 --- a/internal/cmd/project/describe/describe_test.go +++ b/internal/cmd/project/describe/describe_test.go @@ -19,10 +19,20 @@ type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &resourcemanager.APIClient{} var testProjectId = uuid.NewString() +var testProjectId2 = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testProjectId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, includeParentsFlag: "false", } for _, mod := range mods { @@ -34,10 +44,11 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, + ProjectId: "", Verbosity: globalflags.VerbosityDefault, }, IncludeParents: false, + ArgProjectId: testProjectId, } for _, mod := range mods { mod(model) @@ -56,6 +67,7 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiGetProjectRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string labelValues []string isValid bool @@ -63,17 +75,42 @@ func TestParseInput(t *testing.T) { }{ { description: "base", + argValues: fixtureArgValues(), flagValues: fixtureFlagValues(), isValid: true, expectedModel: fixtureInputModel(), }, { description: "no values", + argValues: []string{}, flagValues: map[string]string{}, isValid: false, }, + { + description: "project id arg takes precedence", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = testProjectId2 + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = testProjectId2 + }), + }, + { + description: "project id arg missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = testProjectId + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = testProjectId + }), + }, { description: "project id missing", + argValues: []string{}, flagValues: fixtureFlagValues(func(flagValues map[string]string) { delete(flagValues, projectIdFlag) }), @@ -113,6 +150,14 @@ func TestParseInput(t *testing.T) { } } + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + err = cmd.ValidateRequiredFlags() if err != nil { if !tt.isValid { @@ -121,7 +166,7 @@ func TestParseInput(t *testing.T) { t.Fatalf("error validating flags: %v", err) } - model, err := parseInput(cmd) + model, err := parseInput(cmd, tt.argValues) if err != nil { if !tt.isValid { return diff --git a/internal/cmd/project/list/list.go b/internal/cmd/project/list/list.go index 61ed1cdde..8d85234ed 100644 --- a/internal/cmd/project/list/list.go +++ b/internal/cmd/project/list/list.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -48,6 +49,9 @@ func NewCmd(p *print.Printer) *cobra.Command { Long: "Lists all STACKIT projects that match certain criteria.", Args: args.NoArgs, Example: examples.Build( + examples.NewExample( + `List all STACKIT projects that the authenticated user or service account is a member of`, + "$ stackit project list"), examples.NewExample( `List all STACKIT projects that are children of a specific parent`, "$ stackit project list --parent-id xxx"), @@ -95,9 +99,6 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(creationTimeAfterFlag, "", "Filter by creation timestamp, in a date-time with the RFC3339 layout format, e.g. 2023-01-01T00:00:00Z. The list of projects that were created after the given timestamp will be shown") cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") cmd.Flags().Int64(pageSizeFlag, pageSizeDefault, "Number of items fetched in each API call. Does not affect the number of items in the command output") - - // At least one of parent-id, project-id-like or member flag must be provided - cmd.MarkFlagsOneRequired(parentIdFlag, projectIdLikeFlag, memberFlag) } func parseInput(cmd *cobra.Command) (*inputModel, error) { @@ -138,7 +139,7 @@ func parseInput(cmd *cobra.Command) (*inputModel, error) { }, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient resourceManagerClient, offset int) resourcemanager.ApiListProjectsRequest { +func buildRequest(ctx context.Context, model *inputModel, apiClient resourceManagerClient, offset int) (resourcemanager.ApiListProjectsRequest, error) { req := apiClient.ListProjects(ctx) if model.ParentId != nil { req = req.ContainerParentId(*model.ParentId) @@ -152,9 +153,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient resourceMana if model.CreationTimeAfter != nil { req = req.CreationTimeStart(*model.CreationTimeAfter) } + + if model.ParentId == nil && model.ProjectIdLike == nil && model.Member == nil { + email, err := auth.GetAuthField(auth.USER_EMAIL) + if err != nil { + return req, fmt.Errorf("get email of authenticated user: %w", err) + } + req = req.Member(email) + } req = req.Limit(float32(model.PageSize)) req = req.Offset(float32(offset)) - return req + return req, nil } type resourceManagerClient interface { @@ -170,7 +179,10 @@ func fetchProjects(ctx context.Context, model *inputModel, apiClient resourceMan projects := []resourcemanager.ProjectResponse{} for { // Call API - req := buildRequest(ctx, model, apiClient, offset) + req, err := buildRequest(ctx, model, apiClient, offset) + if err != nil { + return nil, fmt.Errorf("build list projects request: %w", err) + } resp, err := req.Execute() if err != nil { return nil, fmt.Errorf("get projects: %w", err) diff --git a/internal/cmd/project/list/list_test.go b/internal/cmd/project/list/list_test.go index 3d505ac58..b89adca73 100644 --- a/internal/cmd/project/list/list_test.go +++ b/internal/cmd/project/list/list_test.go @@ -9,8 +9,10 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/zalando/go-keyring" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -139,15 +141,12 @@ func TestParseInput(t *testing.T) { { description: "no values", flagValues: map[string]string{}, - isValid: false, - }, - { - description: "none of required fields provided", - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, parentIdFlag) - delete(flagValues, memberFlag) + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ParentId = nil + model.Member = nil + model.CreationTimeAfter = nil }), - isValid: false, }, { description: "projectIdLike invalid", @@ -258,6 +257,17 @@ func TestParseInput(t *testing.T) { } func TestBuildRequest(t *testing.T) { + keyring.MockInit() + err := auth.SetAuthField(auth.USER_EMAIL, "test@test.com") + if err != nil { + t.Fatalf("Failed to set auth user email: %v", err) + } + + authUserEmail, err := auth.GetAuthField(auth.USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get auth user email: %v", err) + } + tests := []struct { description string model *inputModel @@ -278,12 +288,12 @@ func TestBuildRequest(t *testing.T) { expectedRequest: fixtureRequest().Offset(10), }, { - description: "required fields only", + description: "fetch email from auth user", model: &inputModel{ PageSize: pageSizeDefault, }, offset: 1, - expectedRequest: testClient.ListProjects(testCtx).Offset(1).Limit(pageSizeDefault), + expectedRequest: testClient.ListProjects(testCtx).Offset(1).Limit(pageSizeDefault).Member(authUserEmail), }, { description: "projectIdLike set", @@ -299,7 +309,10 @@ func TestBuildRequest(t *testing.T) { if tt.projectIdLike != nil { tt.model.ProjectIdLike = tt.projectIdLike } - request := buildRequest(testCtx, tt.model, testClient, tt.offset) + request, err := buildRequest(testCtx, tt.model, testClient, tt.offset) + if err != nil { + t.Fatalf("Failed to build request: %v", err) + } diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), diff --git a/internal/cmd/project/member/list/list.go b/internal/cmd/project/member/list/list.go index 4f2335259..f2c5706a7 100644 --- a/internal/cmd/project/member/list/list.go +++ b/internal/cmd/project/member/list/list.go @@ -45,13 +45,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Example: examples.Build( examples.NewExample( `List all members of a project`, - "$ stackit project role list --project-id xxx"), + "$ stackit project member list --project-id xxx"), examples.NewExample( `List all members of a project, sorted by role`, - "$ stackit project role list --project-id xxx --sort-by role"), + "$ stackit project member list --project-id xxx --sort-by role"), examples.NewExample( `List up to 10 members of a project`, - "$ stackit project role list --project-id xxx --limit 10"), + "$ stackit project member list --project-id xxx --limit 10"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/rabbitmq/plans/plans.go b/internal/cmd/rabbitmq/plans/plans.go index 732616f22..6a2383c70 100644 --- a/internal/cmd/rabbitmq/plans/plans.go +++ b/internal/cmd/rabbitmq/plans/plans.go @@ -128,16 +128,16 @@ func outputResult(p *print.Printer, outputFormat string, plans []rabbitmq.Offeri return nil default: table := tables.NewTable() - table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { o := plans[i] for j := range *o.Plans { plan := (*o.Plans)[j] - table.AddRow(*o.Name, *plan.Id, *plan.Name, *plan.Description) + table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description) } table.AddSeparator() } - table.EnableAutoMergeOnColumns(1) + table.EnableAutoMergeOnColumns(1, 2) err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) diff --git a/internal/cmd/redis/plans/plans.go b/internal/cmd/redis/plans/plans.go index d6595924e..376940274 100644 --- a/internal/cmd/redis/plans/plans.go +++ b/internal/cmd/redis/plans/plans.go @@ -128,16 +128,16 @@ func outputResult(p *print.Printer, outputFormat string, plans []redis.Offering) return nil default: table := tables.NewTable() - table.SetHeader("OFFERING NAME", "ID", "NAME", "DESCRIPTION") + table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { o := plans[i] for j := range *o.Plans { plan := (*o.Plans)[j] - table.AddRow(*o.Name, *plan.Id, *plan.Name, *plan.Description) + table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description) } table.AddSeparator() } - table.EnableAutoMergeOnColumns(1) + table.EnableAutoMergeOnColumns(1, 2) err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) diff --git a/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go new file mode 100644 index 000000000..c36402d44 --- /dev/null +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go @@ -0,0 +1,125 @@ +package completerotation + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("complete-rotation %s", clusterNameArg), + Short: "Completes the rotation of the credentials associated to a SKE cluster", + Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s\n%s\n\n%s\n%s\n%s", + "Completes the rotation of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster.", + "This is step 2 of a 2-step process to rotate all SKE cluster credentials. Tasks accomplished in this phase include:", + " - The old certification authority will be dropped from the package.", + " - The old signing key for the service account will be dropped from the bundle.", + "To ensure continued access to the Kubernetes cluster, please update your kubeconfig with the new credentials:", + " $ stackit ske kubeconfig create my-cluster", + "If you haven't, please start the process by running:", + " $ stackit ske credentials start-rotation my-cluster", + "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html", + ), + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Complete the rotation of the credentials associated to the SKE cluster with name "my-cluster"`, + "$ stackit ske credentials complete-rotation my-cluster", + ), + examples.NewExample( + `Flow of the 2-step process to rotate all SKE cluster credentials, including generating a new kubeconfig file`, + "$ stackit ske credentials start-rotation my-cluster", + "$ stackit ske kubeconfig create my-cluster", + "$ stackit ske credentials complete-rotation my-cluster", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to complete the rotation of the credentials for SKE cluster %q?", model.ClusterName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("complete rotation of SKE credentials: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Completing credentials rotation") + _, err = wait.CompleteCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for completing SKE credentials rotation %w", err) + } + s.Stop() + } + + operationState := "Rotation of credentials is completed" + if model.Async { + operationState = "Triggered completion of credentials rotation" + } + p.Info("%s for cluster %q\n", operationState, model.ClusterName) + p.Info("Consider updating your kubeconfig with the new credentials, create a new kubeconfig by running:\n $ stackit ske kubeconfig create %s\n", model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiCompleteCredentialsRotationRequest { + req := apiClient.CompleteCredentialsRotation(ctx, model.ProjectId, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go b/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go new file mode 100644 index 000000000..d90046e38 --- /dev/null +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go @@ -0,0 +1,204 @@ +package completerotation + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiCompleteCredentialsRotationRequest)) ske.ApiCompleteCredentialsRotationRequest { + request := testClient.CompleteCredentialsRotation(testCtx, testProjectId, testClusterName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiCompleteCredentialsRotationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/credentials/credentials.go b/internal/cmd/ske/credentials/credentials.go index 582f5247f..9d421ad7d 100644 --- a/internal/cmd/ske/credentials/credentials.go +++ b/internal/cmd/ske/credentials/credentials.go @@ -1,8 +1,10 @@ package credentials import ( + completerotation "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/complete-rotation" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/rotate" + startrotation "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/start-rotation" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -25,4 +27,6 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(describe.NewCmd(p)) cmd.AddCommand(rotate.NewCmd(p)) + cmd.AddCommand(startrotation.NewCmd(p)) + cmd.AddCommand(completerotation.NewCmd(p)) } diff --git a/internal/cmd/ske/credentials/describe/describe.go b/internal/cmd/ske/credentials/describe/describe.go index 19662340a..2707a2588 100644 --- a/internal/cmd/ske/credentials/describe/describe.go +++ b/internal/cmd/ske/credentials/describe/describe.go @@ -33,6 +33,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Short: "Shows details of the credentials associated to a SKE cluster", Long: "Shows details of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster", Args: args.SingleArg(clusterNameArg, nil), + Deprecated: fmt.Sprintf("%s\n%s\n%s\n%s\n", + "and will be removed in a future release.", + "Please use the following command to obtain a kubeconfig file instead:", + " $ stackit ske kubeconfig create CLUSTER_NAME", + "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html", + ), Example: examples.Build( examples.NewExample( `Get details of the credentials associated to the SKE cluster with name "my-cluster"`, diff --git a/internal/cmd/ske/credentials/rotate/rotate.go b/internal/cmd/ske/credentials/rotate/rotate.go index 7de07b9f8..d6ea8d9c3 100644 --- a/internal/cmd/ske/credentials/rotate/rotate.go +++ b/internal/cmd/ske/credentials/rotate/rotate.go @@ -32,6 +32,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Short: "Rotates credentials associated to a SKE cluster", Long: "Rotates credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. The old credentials will be invalid after the operation.", Args: args.SingleArg(clusterNameArg, nil), + Deprecated: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n", + "and will be removed in a future release.", + "Please use the 2-step credential rotation flow instead, by running the commands:", + " $ stackit ske credentials start-rotation CLUSTER_NAME", + " $ stackit ske credentials complete-rotation CLUSTER_NAME", + "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html", + ), Example: examples.Build( examples.NewExample( `Rotate credentials associated to the SKE cluster with name "my-cluster"`, diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation.go b/internal/cmd/ske/credentials/start-rotation/start_rotation.go new file mode 100644 index 000000000..fac0e617d --- /dev/null +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation.go @@ -0,0 +1,128 @@ +package startrotation + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("start-rotation %s", clusterNameArg), + Short: "Starts the rotation of the credentials associated to a SKE cluster", + Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n\n%s\n%s\n%s\n%s\n%s", + "Starts the rotation of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster.", + "This is step 1 of a 2-step process to rotate all SKE cluster credentials. Tasks accomplished in this phase include:", + " - Rolling recreation of all worker nodes", + " - A new Certificate Authority (CA) will be established and incorporated into the existing CA bundle.", + " - A new etcd encryption key is generated and added to the Certificate Authority (CA) bundle.", + " - A new signing key will be generated for the service account and added to the Certificate Authority (CA) bundle.", + " - The kube-apiserver will rewrite all secrets in the cluster, encrypting them with the new encryption key.", + "The old CA, encryption key and signing key will be retained until the rotation is completed.", + "After completing the rotation of credentials, you can generate a new kubeconfig file by running:", + " $ stackit ske kubeconfig create my-cluster", + "Complete the rotation by running:", + " $ stackit ske credentials complete-rotation my-cluster", + "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html", + ), + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Start the rotation of the credentials associated to the SKE cluster with name "my-cluster"`, + "$ stackit ske credentials start-rotation my-cluster"), + examples.NewExample( + `Flow of the 2-step process to rotate all SKE cluster credentials, including generating a new kubeconfig file`, + "$ stackit ske credentials start-rotation my-cluster", + "$ stackit ske kubeconfig create my-cluster", + "$ stackit ske credentials complete-rotation my-cluster", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to start the rotation of the credentials for SKE cluster %q?", model.ClusterName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("start rotation of SKE credentials: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Starting credentials rotation") + _, err = wait.StartCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for start SKE credentials rotation %w", err) + } + s.Stop() + } + + operationState := "Rotation of credentials is ready to be completed" + if model.Async { + operationState = "Triggered start of credentials rotation" + } + p.Info("%s for cluster %q\n", operationState, model.ClusterName) + p.Info("Complete the rotation by running:\n $ stackit ske credentials complete-rotation %s\n", model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiStartCredentialsRotationRequest { + req := apiClient.StartCredentialsRotation(ctx, model.ProjectId, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go b/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go new file mode 100644 index 000000000..763fb93b2 --- /dev/null +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go @@ -0,0 +1,204 @@ +package startrotation + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiStartCredentialsRotationRequest)) ske.ApiStartCredentialsRotationRequest { + request := testClient.StartCredentialsRotation(testCtx, testProjectId, testClusterName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiStartCredentialsRotationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go new file mode 100644 index 000000000..94151bc9f --- /dev/null +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -0,0 +1,163 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +const ( + clusterNameArg = "CLUSTER_NAME" + + expirationFlag = "expiration" + filepathFlag = "filepath" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string + Filepath *string + ExpirationTime *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("create %s", clusterNameArg), + Short: "Creates a kubeconfig for an SKE cluster", + Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s", + "Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster.", + "By default the kubeconfig is created in the .kube folder, in the user's home directory. The kubeconfig file will be overwritten if it already exists.", + "You can override this behavior by specifying a custom filepath with the --filepath flag.", + "An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 1h.", + "Note that the format is , e.g. 30d for 30 days and you can't combine units."), + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Create a kubeconfig for the SKE cluster with name "my-cluster"`, + "$ stackit ske kubeconfig create my-cluster"), + examples.NewExample( + `Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days`, + "$ stackit ske kubeconfig create my-cluster --expiration 30d"), + examples.NewExample( + `Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months`, + "$ stackit ske kubeconfig create my-cluster --expiration 2M"), + examples.NewExample( + `Create a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath`, + "$ stackit ske kubeconfig create my-cluster --filepath /path/to/config"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + 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) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build kubeconfig create request: %w", err) + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create kubeconfig for SKE cluster: %w", err) + } + + // Create the config file + if resp.Kubeconfig == nil { + return fmt.Errorf("no kubeconfig returned from the API") + } + + var kubeconfigPath string + if model.Filepath == nil { + kubeconfigPath, err = skeUtils.GetDefaultKubeconfigPath() + if err != nil { + return fmt.Errorf("get default kubeconfig path: %w", err) + } + } else { + kubeconfigPath = *model.Filepath + } + + err = skeUtils.WriteConfigFile(kubeconfigPath, *resp.Kubeconfig) + if err != nil { + return fmt.Errorf("write kubeconfig file: %w", err) + } + + p.Outputf("Created kubeconfig file for cluster %s in %q, with expiration date %v (UTC)\n", model.ClusterName, kubeconfigPath, *resp.ExpirationTimestamp) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(expirationFlag, "e", "", "Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h") + cmd.Flags().String(filepathFlag, "", "Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory.") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + expTime := flags.FlagToStringPointer(cmd, expirationFlag) + + if expTime != nil { + var err error + expTime, err = skeUtils.ConvertToSeconds(*expTime) + if err != nil { + return nil, &errors.FlagValidationError{ + Flag: expirationFlag, + Details: err.Error(), + } + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + Filepath: flags.FlagToStringPointer(cmd, filepathFlag), + ExpirationTime: expTime, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiCreateKubeconfigRequest, error) { + req := apiClient.CreateKubeconfig(ctx, model.ProjectId, model.ClusterName) + + payload := ske.CreateKubeconfigPayload{} + + if model.ExpirationTime != nil { + payload.ExpirationSeconds = model.ExpirationTime + } + + return req.CreateKubeconfigPayload(payload), nil +} diff --git a/internal/cmd/ske/kubeconfig/create/create_test.go b/internal/cmd/ske/kubeconfig/create/create_test.go new file mode 100644 index 000000000..6326ed3b3 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/create/create_test.go @@ -0,0 +1,237 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.ApiCreateKubeconfigRequest { + request := testClient.CreateKubeconfig(testCtx, testProjectId, testClusterName) + request = request.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{}) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "30d expiration time", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["expiration"] = "30d" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ExpirationTime = utils.Ptr("2592000") + }), + }, + + { + description: "custom filepath", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["filepath"] = "/path/to/config" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filepath = utils.Ptr("/path/to/config") + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiCreateKubeconfigRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "expiration time", + model: fixtureInputModel(func(model *inputModel) { + model.ExpirationTime = utils.Ptr("2592000") + }), + expectedRequest: fixtureRequest().CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ + ExpirationSeconds: utils.Ptr("2592000")}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, _ := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/kubeconfig/kubeconfig.go b/internal/cmd/ske/kubeconfig/kubeconfig.go new file mode 100644 index 000000000..3370a8529 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/kubeconfig.go @@ -0,0 +1,26 @@ +package kubeconfig + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig/create" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "kubeconfig", + Short: "Provides functionality for SKE kubeconfig", + Long: "Provides functionality for STACKIT Kubernetes Engine (SKE) kubeconfig.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(create.NewCmd(p)) +} diff --git a/internal/cmd/ske/ske.go b/internal/cmd/ske/ske.go index 4b497ab20..04438af65 100644 --- a/internal/cmd/ske/ske.go +++ b/internal/cmd/ske/ske.go @@ -6,6 +6,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/disable" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/enable" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -29,6 +30,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(describe.NewCmd(p)) cmd.AddCommand(enable.NewCmd(p)) + cmd.AddCommand(kubeconfig.NewCmd(p)) cmd.AddCommand(disable.NewCmd(p)) cmd.AddCommand(cluster.NewCmd(p)) cmd.AddCommand(credentials.NewCmd(p)) diff --git a/internal/pkg/args/args.go b/internal/pkg/args/args.go index 2307ed342..961480ed1 100644 --- a/internal/pkg/args/args.go +++ b/internal/pkg/args/args.go @@ -42,3 +42,28 @@ func SingleArg(argName string, validate func(value string) error) cobra.Position return nil } } + +// SingleOptionalArg checks if one or no arguments were provided and validates it if provided +// using the validate function. It returns an error if more than one argument is provided, or if +// the argument is invalid. For no validation, you can pass a nil validate function +func SingleOptionalArg(argName string, validate func(value string) error) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return &errors.SingleOptionalArgExpectedError{ + Cmd: cmd, + Expected: argName, + Count: len(args), + } + } + if len(args) == 1 && validate != nil { + err := validate(args[0]) + if err != nil { + return &errors.ArgValidationError{ + Arg: argName, + Details: err.Error(), + } + } + } + return nil + } +} diff --git a/internal/pkg/args/args_test.go b/internal/pkg/args/args_test.go index ad477e50e..2a1360b44 100644 --- a/internal/pkg/args/args_test.go +++ b/internal/pkg/args/args_test.go @@ -107,3 +107,74 @@ func TestSingleArg(t *testing.T) { }) } } + +func TestSingleOptionalArg(t *testing.T) { + tests := []struct { + description string + args []string + validateFunc func(value string) error + isValid bool + }{ + { + description: "valid", + args: []string{"arg"}, + validateFunc: func(value string) error { + return nil + }, + isValid: true, + }, + { + description: "no_arg", + args: []string{}, + isValid: true, + }, + { + description: "more_than_one_arg", + args: []string{"arg", "arg2"}, + isValid: false, + }, + { + description: "empty_arg", + args: []string{""}, + isValid: true, + }, + { + description: "invalid_arg", + args: []string{"arg"}, + validateFunc: func(value string) error { + return fmt.Errorf("error") + }, + isValid: false, + }, + { + description: "nil validation function", + args: []string{"arg"}, + validateFunc: nil, + isValid: true, + }, + { + description: "nil validation function, no args", + args: []string{}, + validateFunc: nil, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Short: "Test command", + } + + argFunction := SingleOptionalArg("test", tt.validateFunc) + err := argFunction(cmd, tt.args) + + if tt.isValid && err != nil { + t.Fatalf("should not have failed: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("should have failed") + } + }) + } +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 0cc3630e1..08eac0b98 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -89,11 +89,6 @@ func InitConfig() { cobra.CheckErr(err) setConfigDefaults() - err = viper.WriteConfigAs(configFilePath) - cobra.CheckErr(err) - - // Needs to be done after WriteConfigAs, otherwise it would write - // the environment variables to the config file viper.AutomaticEnv() viper.SetEnvPrefix("stackit") } diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 711526a68..d421aa1b7 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -107,6 +107,8 @@ For more details on the available storages for the configured flavor (%[3]s), ru SINGLE_ARG_EXPECTED = `expected 1 argument %q, %d were provided` + SINGLE_OPTIONAL_ARG_EXPECTED = `expected no more than 1 argument %q, %d were provided` + SUBCOMMAND_UNKNOWN = `unknown subcommand %q` SUBCOMMAND_MISSING = `missing subcommand` @@ -260,6 +262,17 @@ func (e *SingleArgExpectedError) Error() string { return AppendUsageTip(err, e.Cmd).Error() } +type SingleOptionalArgExpectedError struct { + Cmd *cobra.Command + Expected string + Count int +} + +func (e *SingleOptionalArgExpectedError) Error() string { + err := fmt.Errorf(SINGLE_OPTIONAL_ARG_EXPECTED, e.Expected, e.Count) + return AppendUsageTip(err, e.Cmd).Error() +} + // Used when an unexpected non-flag input (either arg or subcommand) is found type InputUnknownError struct { ProvidedInput string diff --git a/internal/pkg/services/postgresflex/utils/utils.go b/internal/pkg/services/postgresflex/utils/utils.go index d5582b2f4..298df752e 100644 --- a/internal/pkg/services/postgresflex/utils/utils.go +++ b/internal/pkg/services/postgresflex/utils/utils.go @@ -149,6 +149,14 @@ func GetInstanceName(ctx context.Context, apiClient PostgresFlexClient, projectI return *resp.Item.Name, nil } +func GetInstanceStatus(ctx context.Context, apiClient PostgresFlexClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get PostgreSQL Flex instance: %w", err) + } + return *resp.Item.Status, nil +} + func GetUserName(ctx context.Context, apiClient PostgresFlexClient, projectId, instanceId, userId string) (string, error) { resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId) if err != nil { diff --git a/internal/pkg/services/postgresflex/utils/utils_test.go b/internal/pkg/services/postgresflex/utils/utils_test.go index e5a2c6455..c25d4a1d3 100644 --- a/internal/pkg/services/postgresflex/utils/utils_test.go +++ b/internal/pkg/services/postgresflex/utils/utils_test.go @@ -21,6 +21,7 @@ var ( const ( testInstanceName = "instance" testUserName = "user" + testStatus = "running" ) type postgresFlexClientMocked struct { @@ -469,6 +470,56 @@ func TestGetInstanceName(t *testing.T) { } } +func TestGetInstanceStatus(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *postgresflex.InstanceResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Status: utils.Ptr(testStatus), + }, + }, + isValid: true, + expectedOutput: testStatus, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &postgresFlexClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceStatus(context.Background(), client, testProjectId, testInstanceId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + func TestGetUserName(t *testing.T) { tests := []struct { description string diff --git a/internal/pkg/services/ske/utils/utils.go b/internal/pkg/services/ske/utils/utils.go index b055d12a9..901e961c3 100644 --- a/internal/pkg/services/ske/utils/utils.go +++ b/internal/pkg/services/ske/utils/utils.go @@ -3,6 +3,9 @@ package utils import ( "context" "fmt" + "os" + "path/filepath" + "strconv" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -191,3 +194,74 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) return output, nil } + +// ConvertToSeconds converts a time string to seconds. +// The time string must be in the format of , where unit is one of s, m, h, d, M. +func ConvertToSeconds(timeStr string) (*string, error) { + if len(timeStr) < 2 { + return nil, fmt.Errorf("invalid time: %s", timeStr) + } + + unit := timeStr[len(timeStr)-1:] + + valueStr := timeStr[:len(timeStr)-1] + value, err := strconv.ParseUint(valueStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid time value: %s", valueStr) + } + + var multiplier uint64 + switch unit { + // second + case "s": + multiplier = 1 + // minute + case "m": + multiplier = 60 + // hour + case "h": + multiplier = 60 * 60 + // day + case "d": + multiplier = 60 * 60 * 24 + // month, assume 30 days + case "M": + multiplier = 60 * 60 * 24 * 30 + default: + return nil, fmt.Errorf("invalid time unit: %s", unit) + } + + result := uint64(value) * multiplier + return utils.Ptr(strconv.FormatUint(result, 10)), nil +} + +// WriteConfigFile writes the given data to the given path. +// The directory is created if it does not exist. +func WriteConfigFile(configPath, data string) error { + if data == "" { + return fmt.Errorf("no data to write") + } + + dir := filepath.Dir(configPath) + + err := os.MkdirAll(dir, 0o700) + if err != nil { + return fmt.Errorf("create config directory: %w", err) + } + + err = os.WriteFile(configPath, []byte(data), 0o600) + if err != nil { + return fmt.Errorf("write file: %w", err) + } + return nil +} + +// GetDefaultKubeconfigPath returns the default location for the kubeconfig file. +func GetDefaultKubeconfigPath() (string, error) { + userHome, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get user home directory: %w", err) + } + + return filepath.Join(userHome, ".kube", "config"), nil +} diff --git a/internal/pkg/services/ske/utils/utils_test.go b/internal/pkg/services/ske/utils/utils_test.go index 52e151891..fe977e1f2 100644 --- a/internal/pkg/services/ske/utils/utils_test.go +++ b/internal/pkg/services/ske/utils/utils_test.go @@ -3,6 +3,8 @@ package utils import ( "context" "fmt" + "os" + "path/filepath" "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -441,3 +443,170 @@ func TestGetDefaultPayload(t *testing.T) { }) } } + +func TestConvertToSeconds(t *testing.T) { + tests := []struct { + description string + expirationTime string + isValid bool + expectedOutput string + }{ + { + description: "seconds", + expirationTime: "30s", + isValid: true, + expectedOutput: "30", + }, + { + description: "minutes", + expirationTime: "30m", + isValid: true, + expectedOutput: "1800", + }, + { + description: "hours", + expirationTime: "30h", + isValid: true, + expectedOutput: "108000", + }, + { + description: "days", + expirationTime: "30d", + isValid: true, + expectedOutput: "2592000", + }, + { + description: "months", + expirationTime: "30M", + isValid: true, + expectedOutput: "77760000", + }, + { + description: "leading zero", + expirationTime: "0030M", + isValid: true, + expectedOutput: "77760000", + }, + { + description: "invalid unit", + expirationTime: "30x", + isValid: false, + }, + { + description: "invalid unit 2", + expirationTime: "3000abcdef", + isValid: false, + }, + { + description: "invalid unit 3", + expirationTime: "3000abcdef000", + isValid: false, + }, + { + description: "invalid time", + expirationTime: "x", + isValid: false, + }, + { + description: "empty", + expirationTime: "", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := ConvertToSeconds(tt.expirationTime) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if *output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, *output) + } + }) + } +} + +func TestWriteConfigFile(t *testing.T) { + tests := []struct { + description string + location string + kubeconfig string + isValid bool + isLocationDir bool + isLocationEmpty bool + expectedErr string + }{ + { + description: "base", + location: filepath.Join("base", "config"), + kubeconfig: "kubeconfig", + isValid: true, + }, + { + description: "empty location", + location: "", + kubeconfig: "kubeconfig", + isValid: false, + isLocationEmpty: true, + }, + { + description: "path is only dir", + location: "only_dir", + kubeconfig: "kubeconfig", + isValid: false, + isLocationDir: true, + }, + { + description: "empty kubeconfig", + location: filepath.Join("empty", "config"), + kubeconfig: "", + isValid: false, + }, + } + + baseTestDir := "test_data/" + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testLocation := filepath.Join(baseTestDir, tt.location) + // make sure empty case still works + if tt.isLocationEmpty { + testLocation = "" + } + // filepath Join cleans trailing separators + if tt.isLocationDir { + testLocation += string(filepath.Separator) + } + err := WriteConfigFile(testLocation, tt.kubeconfig) + + 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 { + data, err := os.ReadFile(testLocation) + if err != nil { + t.Errorf("could not read file: %s", tt.location) + } + if string(data) != tt.kubeconfig { + t.Errorf("expected file content to be %s, got %s", tt.kubeconfig, string(data)) + } + } + }) + } + // Cleanup + err := os.RemoveAll(baseTestDir) + if err != nil { + t.Errorf("failed cleaning test data") + } +}