diff --git a/docs/stackit_ske_cluster.md b/docs/stackit_ske_cluster.md index 7df9ba39e..a575e5495 100644 --- a/docs/stackit_ske_cluster.md +++ b/docs/stackit_ske_cluster.md @@ -30,10 +30,14 @@ stackit ske cluster [flags] ### SEE ALSO * [stackit ske](./stackit_ske.md) - Provides functionality for SKE -* [stackit ske cluster create](./stackit_ske_cluster_create.md) - Creates an SKE cluster +* [stackit ske cluster create](./stackit_ske_cluster_create.md) - Creates a SKE cluster * [stackit ske cluster delete](./stackit_ske_cluster_delete.md) - Deletes a SKE cluster -* [stackit ske cluster describe](./stackit_ske_cluster_describe.md) - Shows details of a SKE cluster +* [stackit ske cluster describe](./stackit_ske_cluster_describe.md) - Shows details of a SKE cluster * [stackit ske cluster generate-payload](./stackit_ske_cluster_generate-payload.md) - Generates a payload to create/update SKE clusters +* [stackit ske cluster hibernate](./stackit_ske_cluster_hibernate.md) - Trigger hibernate for a SKE cluster * [stackit ske cluster list](./stackit_ske_cluster_list.md) - Lists all SKE clusters -* [stackit ske cluster update](./stackit_ske_cluster_update.md) - Updates an SKE cluster +* [stackit ske cluster maintenance](./stackit_ske_cluster_maintenance.md) - Trigger maintenance for a SKE cluster +* [stackit ske cluster reconcile](./stackit_ske_cluster_reconcile.md) - Trigger reconcile for a SKE cluster +* [stackit ske cluster update](./stackit_ske_cluster_update.md) - Updates a SKE cluster +* [stackit ske cluster wakeup](./stackit_ske_cluster_wakeup.md) - Trigger wakeup from hibernation for a SKE cluster diff --git a/docs/stackit_ske_cluster_create.md b/docs/stackit_ske_cluster_create.md index fa4e0b492..3c94a7bdd 100644 --- a/docs/stackit_ske_cluster_create.md +++ b/docs/stackit_ske_cluster_create.md @@ -1,6 +1,6 @@ ## stackit ske cluster create -Creates an SKE cluster +Creates a SKE cluster ### Synopsis @@ -15,13 +15,13 @@ stackit ske cluster create CLUSTER_NAME [flags] ### Examples ``` - Create an SKE cluster using default configuration + Create a SKE cluster using default configuration $ stackit ske cluster create my-cluster - Create an SKE cluster using an API payload sourced from the file "./payload.json" + Create a SKE cluster using an API payload sourced from the file "./payload.json" $ stackit ske cluster create my-cluster --payload @./payload.json - Create an SKE cluster using an API payload provided as a JSON string + Create a SKE cluster using an API payload provided as a JSON string $ stackit ske cluster create my-cluster --payload "{...}" Generate a payload with default values, and adapt it with custom values for the different configuration options diff --git a/docs/stackit_ske_cluster_delete.md b/docs/stackit_ske_cluster_delete.md index ad2915d87..c1c0407a7 100644 --- a/docs/stackit_ske_cluster_delete.md +++ b/docs/stackit_ske_cluster_delete.md @@ -13,7 +13,7 @@ stackit ske cluster delete CLUSTER_NAME [flags] ### Examples ``` - Delete an SKE cluster with name "my-cluster" + Delete a SKE cluster with name "my-cluster" $ stackit ske cluster delete my-cluster ``` diff --git a/docs/stackit_ske_cluster_describe.md b/docs/stackit_ske_cluster_describe.md index eb30860a9..91b3949fc 100644 --- a/docs/stackit_ske_cluster_describe.md +++ b/docs/stackit_ske_cluster_describe.md @@ -1,10 +1,10 @@ ## stackit ske cluster describe -Shows details of a SKE cluster +Shows details of a SKE cluster ### Synopsis -Shows details of a STACKIT Kubernetes Engine (SKE) cluster. +Shows details of a STACKIT Kubernetes Engine (SKE) cluster. ``` stackit ske cluster describe CLUSTER_NAME [flags] @@ -13,10 +13,10 @@ stackit ske cluster describe CLUSTER_NAME [flags] ### Examples ``` - Get details of an SKE cluster with name "my-cluster" + Get details of a SKE cluster with name "my-cluster" $ stackit ske cluster describe my-cluster - Get details of an SKE cluster with name "my-cluster" in JSON format + Get details of a SKE cluster with name "my-cluster" in JSON format $ stackit ske cluster describe my-cluster --output-format json ``` diff --git a/docs/stackit_ske_cluster_hibernate.md b/docs/stackit_ske_cluster_hibernate.md new file mode 100644 index 000000000..20baddd1b --- /dev/null +++ b/docs/stackit_ske_cluster_hibernate.md @@ -0,0 +1,40 @@ +## stackit ske cluster hibernate + +Trigger hibernate for a SKE cluster + +### Synopsis + +Trigger hibernate for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster hibernate CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger hibernate for a SKE cluster with name "my-cluster" + $ stackit ske cluster hibernate my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster hibernate" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_maintenance.md b/docs/stackit_ske_cluster_maintenance.md new file mode 100644 index 000000000..0a6c6540c --- /dev/null +++ b/docs/stackit_ske_cluster_maintenance.md @@ -0,0 +1,40 @@ +## stackit ske cluster maintenance + +Trigger maintenance for a SKE cluster + +### Synopsis + +Trigger maintenance for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster maintenance CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger maintenance for a SKE cluster with name "my-cluster" + $ stackit ske cluster maintenance my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster maintenance" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_reconcile.md b/docs/stackit_ske_cluster_reconcile.md new file mode 100644 index 000000000..64887316d --- /dev/null +++ b/docs/stackit_ske_cluster_reconcile.md @@ -0,0 +1,40 @@ +## stackit ske cluster reconcile + +Trigger reconcile for a SKE cluster + +### Synopsis + +Trigger reconcile for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster reconcile CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger reconcile for a SKE cluster with name "my-cluster" + $ stackit ske cluster reconcile my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster reconcile" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_update.md b/docs/stackit_ske_cluster_update.md index 5209cc5df..24fa95748 100644 --- a/docs/stackit_ske_cluster_update.md +++ b/docs/stackit_ske_cluster_update.md @@ -1,6 +1,6 @@ ## stackit ske cluster update -Updates an SKE cluster +Updates a SKE cluster ### Synopsis @@ -15,10 +15,10 @@ stackit ske cluster update CLUSTER_NAME [flags] ### Examples ``` - Update an SKE cluster using an API payload sourced from the file "./payload.json" + Update a SKE cluster using an API payload sourced from the file "./payload.json" $ stackit ske cluster update my-cluster --payload @./payload.json - Update an SKE cluster using an API payload provided as a JSON string + Update a SKE cluster using an API payload provided as a JSON string $ stackit ske cluster update my-cluster --payload "{...}" Generate a payload with the current values of a cluster, and adapt it with custom values for the different configuration options diff --git a/docs/stackit_ske_cluster_wakeup.md b/docs/stackit_ske_cluster_wakeup.md new file mode 100644 index 000000000..7b07e9965 --- /dev/null +++ b/docs/stackit_ske_cluster_wakeup.md @@ -0,0 +1,40 @@ +## stackit ske cluster wakeup + +Trigger wakeup from hibernation for a SKE cluster + +### Synopsis + +Trigger wakeup from hibernation for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster wakeup CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger wakeup from hibernation for a SKE cluster with name "my-cluster" + $ stackit ske cluster wakeup my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster wakeup" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_kubeconfig.md b/docs/stackit_ske_kubeconfig.md index 5c7d3adf0..83634e149 100644 --- a/docs/stackit_ske_kubeconfig.md +++ b/docs/stackit_ske_kubeconfig.md @@ -30,6 +30,6 @@ stackit ske kubeconfig [flags] ### SEE ALSO * [stackit ske](./stackit_ske.md) - Provides functionality for SKE -* [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates or update a kubeconfig for an SKE cluster +* [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates or update a kubeconfig for a SKE cluster * [stackit ske kubeconfig login](./stackit_ske_kubeconfig_login.md) - Login plugin for kubernetes clients diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md index d3d0e5622..c0f95423b 100644 --- a/docs/stackit_ske_kubeconfig_create.md +++ b/docs/stackit_ske_kubeconfig_create.md @@ -1,10 +1,10 @@ ## stackit ske kubeconfig create -Creates or update a kubeconfig for an SKE cluster +Creates or update a kubeconfig for a SKE cluster ### Synopsis -Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, if the config exits in the kubeconfig file the information will be updated. +Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, if the config exists in the kubeconfig file the information will be updated. By default, the kubeconfig information of the SKE cluster is merged into the default kubeconfig file of the current user. If the kubeconfig file doesn't exist, a new one will be created. You can override this behavior by specifying a custom filepath with the --filepath flag. diff --git a/go.mod b/go.mod index bba753a65..41c43fc2f 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.1 github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.9.1 github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.2 - github.com/stackitcloud/stackit-sdk-go/services/ske v1.2.0 + github.com/stackitcloud/stackit-sdk-go/services/ske v1.3.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.3.1 github.com/zalando/go-keyring v0.2.6 golang.org/x/mod v0.26.0 diff --git a/go.sum b/go.sum index 82df7eb29..c344eaabc 100644 --- a/go.sum +++ b/go.sum @@ -609,8 +609,8 @@ github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.9.1 h1:6kEct2w github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.9.1/go.mod h1:dScCMWYbsf3B+c6a/5CFoVFcYLqHHkEEc622cHFtGGY= github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.2 h1:s2iag/Gc4tuQH7x5I0n4mQWVhpfl/cj+SVNAFAB5ck0= github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.2/go.mod h1:DFEamKVoOjm/rjMwzfZK0Zg/hwsSkXOibdA4HcC6swk= -github.com/stackitcloud/stackit-sdk-go/services/ske v1.2.0 h1:rNlTSWShnlkW4vbBuJ3a1NPwQfN5H1+mpdjngLqFRdo= -github.com/stackitcloud/stackit-sdk-go/services/ske v1.2.0/go.mod h1:UPPntEOhriZ4dZXEkjtfkGLFKvfA7Q/JAPG/zfwcoyc= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.3.0 h1:hPCpRcWEzwzGONZJsKH+j2TjN1LRTH7Tp/q0TyzmL5M= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.3.0/go.mod h1:jDYRbagjOwKEVsvkxdUErXIvvTOLw9WdBVjaXr5YOD8= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.3.1 h1:r5808lRhtY8l5anft/UwgJEaef1XsoYObmwd3FVai48= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.3.1/go.mod h1:+LYy2pB+tpF0lkkmCf524wvv2Sa49REgEaNh7JGzN6Y= github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= diff --git a/internal/cmd/ske/cluster/cluster.go b/internal/cmd/ske/cluster/cluster.go index 0158b7173..87994c2fa 100644 --- a/internal/cmd/ske/cluster/cluster.go +++ b/internal/cmd/ske/cluster/cluster.go @@ -6,8 +6,12 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/describe" generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/hibernate" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/maintenance" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/reconcile" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/wakeup" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -33,4 +37,8 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(hibernate.NewCmd(params)) + cmd.AddCommand(maintenance.NewCmd(params)) + cmd.AddCommand(reconcile.NewCmd(params)) + cmd.AddCommand(wakeup.NewCmd(params)) } diff --git a/internal/cmd/ske/cluster/create/create.go b/internal/cmd/ske/cluster/create/create.go index 557fb9a3a..af9ba2336 100644 --- a/internal/cmd/ske/cluster/create/create.go +++ b/internal/cmd/ske/cluster/create/create.go @@ -40,7 +40,7 @@ type inputModel struct { func NewCmd(params *params.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", clusterNameArg), - Short: "Creates an SKE cluster", + Short: "Creates a SKE cluster", Long: fmt.Sprintf("%s\n%s\n%s", "Creates a STACKIT Kubernetes Engine (SKE) cluster.", "The payload can be provided as a JSON string or a file path prefixed with \"@\".", @@ -49,13 +49,13 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Create an SKE cluster using default configuration`, + `Create a SKE cluster using default configuration`, "$ stackit ske cluster create my-cluster"), examples.NewExample( - `Create an SKE cluster using an API payload sourced from the file "./payload.json"`, + `Create a SKE cluster using an API payload sourced from the file "./payload.json"`, "$ stackit ske cluster create my-cluster --payload @./payload.json"), examples.NewExample( - `Create an SKE cluster using an API payload provided as a JSON string`, + `Create a SKE cluster using an API payload provided as a JSON string`, `$ stackit ske cluster create my-cluster --payload "{...}"`), examples.NewExample( `Generate a payload with default values, and adapt it with custom values for the different configuration options`, diff --git a/internal/cmd/ske/cluster/delete/delete.go b/internal/cmd/ske/cluster/delete/delete.go index b10871bfe..12068d9e3 100644 --- a/internal/cmd/ske/cluster/delete/delete.go +++ b/internal/cmd/ske/cluster/delete/delete.go @@ -35,7 +35,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Delete an SKE cluster with name "my-cluster"`, + `Delete a SKE cluster with name "my-cluster"`, "$ stackit ske cluster delete my-cluster"), ), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/ske/cluster/describe/describe.go b/internal/cmd/ske/cluster/describe/describe.go index 40bf4897f..04d33baad 100644 --- a/internal/cmd/ske/cluster/describe/describe.go +++ b/internal/cmd/ske/cluster/describe/describe.go @@ -31,15 +31,15 @@ type inputModel struct { func NewCmd(params *params.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", clusterNameArg), - Short: "Shows details of a SKE cluster", - Long: "Shows details of a STACKIT Kubernetes Engine (SKE) cluster.", + Short: "Shows details of a SKE cluster", + Long: "Shows details of a STACKIT Kubernetes Engine (SKE) cluster.", Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Get details of an SKE cluster with name "my-cluster"`, + `Get details of a SKE cluster with name "my-cluster"`, "$ stackit ske cluster describe my-cluster"), examples.NewExample( - `Get details of an SKE cluster with name "my-cluster" in JSON format`, + `Get details of a SKE cluster with name "my-cluster" in JSON format`, "$ stackit ske cluster describe my-cluster --output-format json"), ), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/ske/cluster/hibernate/hibernate.go b/internal/cmd/ske/cluster/hibernate/hibernate.go new file mode 100644 index 000000000..4c0be63ab --- /dev/null +++ b/internal/cmd/ske/cluster/hibernate/hibernate.go @@ -0,0 +1,124 @@ +package hibernate + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "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(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("hibernate %s", clusterNameArg), + Short: "Trigger hibernate for a SKE cluster", + Long: "Trigger hibernate for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger hibernate for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster hibernate my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to trigger hibernate for %q in project %q?", model.ClusterName, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("hibernate SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Hibernating cluster") + _, err = wait.TriggerClusterHibernationWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster hibernation: %w", err) + } + s.Stop() + } + + operationState := "Hibernated" + if model.Async { + operationState = "Triggered hibernation of" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerHibernateRequest { + req := apiClient.TriggerHibernate(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/hibernate/hibernate_test.go b/internal/cmd/ske/cluster/hibernate/hibernate_test.go new file mode 100644 index 000000000..d9d531ef1 --- /dev/null +++ b/internal/cmd/ske/cluster/hibernate/hibernate_test.go @@ -0,0 +1,186 @@ +package hibernate + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiTriggerHibernateRequest)) ske.ApiTriggerHibernateRequest { + request := testClient.TriggerHibernate(testCtx, testProjectId, testRegion, 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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := &cobra.Command{} + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected error due to missing args") + } + return + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("data does not match:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerHibernateRequest + }{ + { + 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, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(tt.expectedRequest), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/maintenance/maintenance.go b/internal/cmd/ske/cluster/maintenance/maintenance.go new file mode 100644 index 000000000..3396ad7c3 --- /dev/null +++ b/internal/cmd/ske/cluster/maintenance/maintenance.go @@ -0,0 +1,124 @@ +package maintenance + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "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(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("maintenance %s", clusterNameArg), + Short: "Trigger maintenance for a SKE cluster", + Long: "Trigger maintenance for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger maintenance for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster maintenance my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to trigger maintenance for %q in project %q?", model.ClusterName, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("trigger maintenance SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Performing cluster maintenance") + _, err = wait.TriggerClusterMaintenanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster maintenance to complete: %w", err) + } + s.Stop() + } + + operationState := "Performed maintenance for" + if model.Async { + operationState = "Triggered maintenance for" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerMaintenanceRequest { + req := apiClient.TriggerMaintenance(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/maintenance/maintenance_test.go b/internal/cmd/ske/cluster/maintenance/maintenance_test.go new file mode 100644 index 000000000..fe0ab07cb --- /dev/null +++ b/internal/cmd/ske/cluster/maintenance/maintenance_test.go @@ -0,0 +1,187 @@ +package maintenance + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func([]string)) []string { + argValues := []string{ + testClusterName, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, m := range mods { + m(model) + } + return model +} + +func fixtureRequest(mods ...func(*ske.ApiTriggerMaintenanceRequest)) ske.ApiTriggerMaintenanceRequest { + request := testClient.TriggerMaintenance(testCtx, testProjectId, testRegion, testClusterName) + for _, m := range mods { + m(&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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := &cobra.Command{} + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected error due to missing args") + } + return + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("input model mismatch:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerMaintenanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + want := tt.expectedRequest + + diff := cmp.Diff(got, want, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(want), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/reconcile/reconcile.go b/internal/cmd/ske/cluster/reconcile/reconcile.go new file mode 100644 index 000000000..54c98dae0 --- /dev/null +++ b/internal/cmd/ske/cluster/reconcile/reconcile.go @@ -0,0 +1,110 @@ +package reconcile + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/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(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("reconcile %s", clusterNameArg), + Short: "Trigger reconcile for a SKE cluster", + Long: "Trigger reconcile for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger reconcile for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster reconcile my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("reconcile SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Performing cluster reconciliation") + _, err = wait.TriggerClusterReconciliationWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster reconciliation: %w", err) + } + s.Stop() + } + + operationState := "Performed reconciliation for" + if model.Async { + operationState = "Triggered reconcile for" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerReconcileRequest { + req := apiClient.TriggerReconcile(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/reconcile/reconcile_test.go b/internal/cmd/ske/cluster/reconcile/reconcile_test.go new file mode 100644 index 000000000..5c96f295b --- /dev/null +++ b/internal/cmd/ske/cluster/reconcile/reconcile_test.go @@ -0,0 +1,187 @@ +package reconcile + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func([]string)) []string { + argValues := []string{ + testClusterName, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, m := range mods { + m(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiTriggerReconcileRequest)) ske.ApiTriggerHibernateRequest { + request := testClient.TriggerReconcile(testCtx, testProjectId, testRegion, testClusterName) + for _, m := range mods { + m(&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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := &cobra.Command{} + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected error due to missing args") + } + return + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("input model mismatch:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerHibernateRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + want := tt.expectedRequest + + diff := cmp.Diff(got, want, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(want), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/update/update.go b/internal/cmd/ske/cluster/update/update.go index 420c06349..8c32f8d95 100644 --- a/internal/cmd/ske/cluster/update/update.go +++ b/internal/cmd/ske/cluster/update/update.go @@ -37,7 +37,7 @@ type inputModel struct { func NewCmd(params *params.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", clusterNameArg), - Short: "Updates an SKE cluster", + Short: "Updates a SKE cluster", Long: fmt.Sprintf("%s\n%s\n%s", "Updates a STACKIT Kubernetes Engine (SKE) cluster.", "The payload can be provided as a JSON string or a file path prefixed with \"@\".", @@ -46,10 +46,10 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Update an SKE cluster using an API payload sourced from the file "./payload.json"`, + `Update a SKE cluster using an API payload sourced from the file "./payload.json"`, "$ stackit ske cluster update my-cluster --payload @./payload.json"), examples.NewExample( - `Update an SKE cluster using an API payload provided as a JSON string`, + `Update a SKE cluster using an API payload provided as a JSON string`, `$ stackit ske cluster update my-cluster --payload "{...}"`), examples.NewExample( `Generate a payload with the current values of a cluster, and adapt it with custom values for the different configuration options`, diff --git a/internal/cmd/ske/cluster/wakeup/wakeup.go b/internal/cmd/ske/cluster/wakeup/wakeup.go new file mode 100644 index 000000000..e7b9b6534 --- /dev/null +++ b/internal/cmd/ske/cluster/wakeup/wakeup.go @@ -0,0 +1,110 @@ +package wakeup + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/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(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("wakeup %s", clusterNameArg), + Short: "Trigger wakeup from hibernation for a SKE cluster", + Long: "Trigger wakeup from hibernation for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger wakeup from hibernation for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster wakeup my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("wakeup SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Performing cluster wakeup") + _, err = wait.TriggerClusterWakeupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster wakeup: %w", err) + } + s.Stop() + } + + operationState := "Performed wakeup of" + if model.Async { + operationState = "Triggered wakeup of" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerWakeupRequest { + req := apiClient.TriggerWakeup(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/wakeup/wakeup_test.go b/internal/cmd/ske/cluster/wakeup/wakeup_test.go new file mode 100644 index 000000000..dd93881c1 --- /dev/null +++ b/internal/cmd/ske/cluster/wakeup/wakeup_test.go @@ -0,0 +1,185 @@ +package wakeup + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func([]string)) []string { + argValues := []string{testClusterName} + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flags := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flags) + } + return flags +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, m := range mods { + m(model) + } + return model +} + +func fixtureRequest(mods ...func(*ske.ApiTriggerWakeupRequest)) ske.ApiTriggerWakeupRequest { + req := testClient.TriggerWakeup(testCtx, testProjectId, testRegion, testClusterName) + for _, m := range mods { + m(&req) + } + return req +} + +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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := &cobra.Command{} + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected failure due to missing args") + } + return + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("input model mismatch:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerHibernateRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + want := tt.expectedRequest + + diff := cmp.Diff(got, want, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(want), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go index fe2907958..681b74db9 100644 --- a/internal/cmd/ske/kubeconfig/create/create.go +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -44,9 +44,9 @@ type inputModel struct { func NewCmd(params *params.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", clusterNameArg), - Short: "Creates or update a kubeconfig for an SKE cluster", + Short: "Creates or update a kubeconfig for a 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, if the config exits in the kubeconfig file the information will be updated.", + "Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, if the config exists in the kubeconfig file the information will be updated.", "By default, the kubeconfig information of the SKE cluster is merged into the default kubeconfig file of the current user. If the kubeconfig file doesn't exist, a new one will be created.", "You can override this behavior by specifying a custom filepath with the --filepath flag.\n", "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.\n", diff --git a/internal/cmd/ske/ske.go b/internal/cmd/ske/ske.go index 137165e06..e782119db 100644 --- a/internal/cmd/ske/ske.go +++ b/internal/cmd/ske/ske.go @@ -28,11 +28,11 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(cluster.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) cmd.AddCommand(enable.NewCmd(params)) cmd.AddCommand(kubeconfig.NewCmd(params)) - cmd.AddCommand(disable.NewCmd(params)) - cmd.AddCommand(cluster.NewCmd(params)) - cmd.AddCommand(credentials.NewCmd(params)) cmd.AddCommand(options.NewCmd(params)) }