From 135d338285431f9b9223966ea4cbbc1dc4586d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Mon, 1 Apr 2024 10:01:32 +0100 Subject: [PATCH 1/6] SKE Credentials Rotation: start-rotation command (#179) * initial start rotation command implementation * update go mod, rename files, generate docs * start-rotation update long description * Improve start-rotation documentation --- docs/stackit_postgresflex_instance_clone.md | 2 +- docs/stackit_ske_credentials.md | 1 + .../stackit_ske_credentials_start-rotation.md | 39 ++++ go.mod | 2 +- go.sum | 4 +- internal/cmd/ske/credentials/credentials.go | 2 + .../start-rotation/start_rotation.go | 118 ++++++++++ .../start-rotation/start_rotation_test.go | 203 ++++++++++++++++++ 8 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 docs/stackit_ske_credentials_start-rotation.md create mode 100644 internal/cmd/ske/credentials/start-rotation/start_rotation.go create mode 100644 internal/cmd/ske/credentials/start-rotation/start_rotation_test.go diff --git a/docs/stackit_postgresflex_instance_clone.md b/docs/stackit_postgresflex_instance_clone.md index 64acbc209..cbc9f1e9b 100644 --- a/docs/stackit_postgresflex_instance_clone.md +++ b/docs/stackit_postgresflex_instance_clone.md @@ -27,7 +27,7 @@ stackit postgresflex instance clone INSTANCE_ID [flags] ``` -h, --help Help for "stackit postgresflex instance clone" - --recovery-timestamp string Recovery timestamp for the instance, specified in UTC time following the format, e.g. 2024-03-12T09:28:00+00:00 + --recovery-timestamp string Recovery timestamp for the instance, in a date-time with the RFC3339 layout format, e.g. 2024-01-01T00:00:00Z --storage-class string Storage class. If not specified, storage class from the existing instance will be used. --storage-size int Storage size (in GB). If not specified, storage size from the existing instance will be used. ``` diff --git a/docs/stackit_ske_credentials.md b/docs/stackit_ske_credentials.md index 28e4beddb..88310d6fb 100644 --- a/docs/stackit_ske_credentials.md +++ b/docs/stackit_ske_credentials.md @@ -30,4 +30,5 @@ stackit ske credentials [flags] * [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 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_start-rotation.md b/docs/stackit_ske_credentials_start-rotation.md new file mode 100644 index 000000000..d9242f9c4 --- /dev/null +++ b/docs/stackit_ske_credentials_start-rotation.md @@ -0,0 +1,39 @@ +## 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 two-step process. +Complete the rotation using the 'stackit ske credentials complete-rotation' command. + +``` +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 +``` + +### 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 +``` + +### SEE ALSO + +* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials + diff --git a/go.mod b/go.mod index 0733646ca..9f8c6b4fe 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 2720421f0..8f4def413 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/ske/credentials/credentials.go b/internal/cmd/ske/credentials/credentials.go index be20e1a72..9c2f56efd 100644 --- a/internal/cmd/ske/credentials/credentials.go +++ b/internal/cmd/ske/credentials/credentials.go @@ -3,6 +3,7 @@ package credentials import ( "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/utils" @@ -24,4 +25,5 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(describe.NewCmd()) cmd.AddCommand(rotate.NewCmd()) + cmd.AddCommand(startrotation.NewCmd()) } 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..3028eb704 --- /dev/null +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation.go @@ -0,0 +1,118 @@ +package startrotation + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/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() *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%s\n%s\n%s\n%s\n%s\n%s\n%s\n\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.", + "Complete the rotation by running:", + " $ stackit ske credentials complete-rotation my-cluster"), + 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"), + ), + 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 = confirm.PromptForConfirmation(cmd, 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(cmd) + 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" + } + cmd.Printf("%s for cluster %q\n", operationState, model.ClusterName) + cmd.Printf("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..9960008d4 --- /dev/null +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go @@ -0,0 +1,203 @@ +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, + }, + 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() + 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) + } + }) + } +} From d15576068941592c8080c7b69945294192e758d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Mon, 1 Apr 2024 14:37:35 +0100 Subject: [PATCH 2/6] SKE Credentials Rotation: complete-rotation command (#181) * initial complete rotation command * complete rotation command: improve documentation * improve documentation --- .../complete-rotation/complete_rotation.go | 123 +++++++++++ .../complete_rotation_test.go | 203 ++++++++++++++++++ internal/cmd/ske/credentials/credentials.go | 2 + .../start-rotation/start_rotation.go | 6 + 4 files changed, 334 insertions(+) create mode 100644 internal/cmd/ske/credentials/complete-rotation/complete_rotation.go create mode 100644 internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go 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..c322a9b3f --- /dev/null +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go @@ -0,0 +1,123 @@ +package completerotation + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/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() *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%s\n%s\n%s\n%s\n\n%s\n%s\n%s\n%s", + "Completes the rotation of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster.", + "To ensure continued access to the Kubernetes cluster, please update your kubeconfig service account to the newly created account.", + "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.", + "If you haven't, please start the process by running:", + " $ stackit ske credentials start-rotation my-cluster", + "After completing the rotation of credentials, you can generate a new kubeconfig file by running:", + " $ stackit ske kubeconfig create my-cluster"), + 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 credentials complete-rotation my-cluster", + "$ stackit ske kubeconfig create 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 = confirm.PromptForConfirmation(cmd, 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(cmd) + 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" + } + cmd.Printf("%s for cluster %q\n", operationState, model.ClusterName) + cmd.Printf("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..42bc1e71e --- /dev/null +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go @@ -0,0 +1,203 @@ +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, + }, + 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() + 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 9c2f56efd..96c2f0d49 100644 --- a/internal/cmd/ske/credentials/credentials.go +++ b/internal/cmd/ske/credentials/credentials.go @@ -1,6 +1,7 @@ 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" @@ -26,4 +27,5 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(describe.NewCmd()) cmd.AddCommand(rotate.NewCmd()) cmd.AddCommand(startrotation.NewCmd()) + cmd.AddCommand(completerotation.NewCmd()) } diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation.go b/internal/cmd/ske/credentials/start-rotation/start_rotation.go index 3028eb704..d56ab6d4f 100644 --- a/internal/cmd/ske/credentials/start-rotation/start_rotation.go +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation.go @@ -46,6 +46,12 @@ func NewCmd() *cobra.Command { 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 credentials complete-rotation my-cluster", + "$ stackit ske kubeconfig create my-cluster", + ), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() From 1aa9ee02c594845d685e9fc42375e70f0f756ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Tue, 2 Apr 2024 11:29:35 +0100 Subject: [PATCH 3/6] SKE Credentials Rotation: kubeconfig create command (#184) * initial kubeconfig create command * finish create command implementation, add testing * fix linting, generate docs * address PR comments * extract funcs to utils, add testing * improve function documentation * fix linting * address PR comments, minor improvements * make utils testing work on all OSes --- docs/stackit_ske.md | 1 + docs/stackit_ske_credentials.md | 1 + ...ackit_ske_credentials_complete-rotation.md | 52 ++++ .../stackit_ske_credentials_start-rotation.md | 18 +- docs/stackit_ske_kubeconfig.md | 32 +++ docs/stackit_ske_kubeconfig_create.md | 50 ++++ internal/cmd/ske/kubeconfig/create/create.go | 152 +++++++++++ .../cmd/ske/kubeconfig/create/create_test.go | 236 ++++++++++++++++++ internal/cmd/ske/kubeconfig/kubeconfig.go | 25 ++ internal/cmd/ske/ske.go | 2 + internal/pkg/services/ske/utils/utils.go | 74 ++++++ internal/pkg/services/ske/utils/utils_test.go | 169 +++++++++++++ 12 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 docs/stackit_ske_credentials_complete-rotation.md create mode 100644 docs/stackit_ske_kubeconfig.md create mode 100644 docs/stackit_ske_kubeconfig_create.md create mode 100644 internal/cmd/ske/kubeconfig/create/create.go create mode 100644 internal/cmd/ske/kubeconfig/create/create_test.go create mode 100644 internal/cmd/ske/kubeconfig/kubeconfig.go diff --git a/docs/stackit_ske.md b/docs/stackit_ske.md index 4cf76387c..0f3834afc 100644 --- a/docs/stackit_ske.md +++ b/docs/stackit_ske.md @@ -33,5 +33,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 88310d6fb..e49497fb5 100644 --- a/docs/stackit_ske_credentials.md +++ b/docs/stackit_ske_credentials.md @@ -28,6 +28,7 @@ stackit ske credentials [flags] ### SEE ALSO * [stackit ske](./stackit_ske.md) - Provides functionality for SKE +* [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 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 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..6afa588ad --- /dev/null +++ b/docs/stackit_ske_credentials_complete-rotation.md @@ -0,0 +1,52 @@ +## 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. +To ensure continued access to the Kubernetes cluster, please update your kubeconfig service account to the newly created account. +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. + +If you haven't, please start the process by running: + $ stackit ske credentials start-rotation my-cluster +After completing the rotation of credentials, you can generate a new kubeconfig file by running: + $ stackit ske kubeconfig create my-cluster + +``` +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 credentials complete-rotation my-cluster + $ stackit ske kubeconfig create 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 +``` + +### 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 index d9242f9c4..825f19306 100644 --- a/docs/stackit_ske_credentials_start-rotation.md +++ b/docs/stackit_ske_credentials_start-rotation.md @@ -4,8 +4,17 @@ 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 two-step process. -Complete the rotation using the 'stackit ske credentials complete-rotation' command. +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. + +Complete the rotation by running: + $ stackit ske credentials complete-rotation my-cluster ``` stackit ske credentials start-rotation CLUSTER_NAME [flags] @@ -16,6 +25,11 @@ stackit ske credentials start-rotation CLUSTER_NAME [flags] ``` 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 credentials complete-rotation my-cluster + $ stackit ske kubeconfig create my-cluster ``` ### Options diff --git a/docs/stackit_ske_kubeconfig.md b/docs/stackit_ske_kubeconfig.md new file mode 100644 index 000000000..ec9e7011f --- /dev/null +++ b/docs/stackit_ske_kubeconfig.md @@ -0,0 +1,32 @@ +## 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 +``` + +### 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..df8765849 --- /dev/null +++ b/docs/stackit_ske_kubeconfig_create.md @@ -0,0 +1,50 @@ +## 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. + +``` +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 location + $ stackit ske kubeconfig create my-cluster --location /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 + -h, --help Help for "stackit ske kubeconfig create" + --location string Folder location to store the kubeconfig file. By default, the kubeconfig is created in the .kube folder, in the user's home directory. +``` + +### 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 +``` + +### SEE ALSO + +* [stackit ske kubeconfig](./stackit_ske_kubeconfig.md) - Provides functionality for SKE kubeconfig + diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go new file mode 100644 index 000000000..e3aacd633 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -0,0 +1,152 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/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" + locationFlag = "location" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string + Location *string + ExpirationTime *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("create %s", clusterNameArg), + Short: "Creates a kubeconfig for an SKE cluster", + Long: fmt.Sprintf("%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."), + 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 location`, + "$ stackit ske kubeconfig create my-cluster --location /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 configuration, if it exists.", model.ClusterName) + err = confirm.PromptForConfirmation(cmd, 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.Location == nil { + kubeconfigPath, err = skeUtils.GetDefaultKubeconfigLocation() + if err != nil { + return fmt.Errorf("get default kubeconfig location: %w", err) + } + } else { + kubeconfigPath = *model.Location + } + + err = skeUtils.WriteConfigFile(kubeconfigPath, *resp.Kubeconfig) + if err != nil { + return fmt.Errorf("write kubeconfig file: %w", err) + } + + fmt.Printf("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(locationFlag, "", "Folder location to store the kubeconfig file. By default, the kubeconfig is created 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{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + Location: flags.FlagToStringPointer(cmd, locationFlag), + ExpirationTime: flags.FlagToStringPointer(cmd, expirationFlag), + }, 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 { + expirationTime, err := skeUtils.ConvertToSeconds(*model.ExpirationTime) + if err != nil { + return req, fmt.Errorf("parse expiration time: %w", err) + } + + payload.ExpirationSeconds = 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..fb507ebd6 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/create/create_test.go @@ -0,0 +1,236 @@ +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, + }, + 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("30d") + }), + }, + + { + description: "custom location", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["location"] = "/path/to/config" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Location = 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() + 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("30d") + }), + 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..5411cd0a2 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/kubeconfig.go @@ -0,0 +1,25 @@ +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/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *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) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) +} diff --git a/internal/cmd/ske/ske.go b/internal/cmd/ske/ske.go index ebfe89036..32a40662c 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/utils" @@ -28,6 +29,7 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(describe.NewCmd()) cmd.AddCommand(enable.NewCmd()) + cmd.AddCommand(kubeconfig.NewCmd()) cmd.AddCommand(disable.NewCmd()) cmd.AddCommand(cluster.NewCmd()) cmd.AddCommand(credentials.NewCmd()) diff --git a/internal/pkg/services/ske/utils/utils.go b/internal/pkg/services/ske/utils/utils.go index b055d12a9..abc9e9089 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 format: %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("unable to parse uint: %s", timeStr) + } + + 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 format: %s", timeStr) + } + + 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 +} + +// GetDefaultKubeconfigLocation returns the default location for the kubeconfig file. +func GetDefaultKubeconfigLocation() (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") + } +} From f6728a79499b2098a59d146d6e414434cb13f261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 3 Apr 2024 09:40:00 +0100 Subject: [PATCH 4/6] SKE Credentials Rotation: fixes and improvements (#188) * improve docs * Improve documentation, address acceptance comments * fix testing, finish renaming filepath flag --- ...ackit_ske_credentials_complete-rotation.md | 9 ++-- .../stackit_ske_credentials_start-rotation.md | 6 ++- docs/stackit_ske_kubeconfig_create.md | 10 ++-- .../complete-rotation/complete_rotation.go | 14 ++--- .../start-rotation/start_rotation.go | 10 ++-- internal/cmd/ske/kubeconfig/create/create.go | 51 +++++++++++-------- .../cmd/ske/kubeconfig/create/create_test.go | 10 ++-- internal/pkg/services/ske/utils/utils.go | 10 ++-- 8 files changed, 73 insertions(+), 47 deletions(-) diff --git a/docs/stackit_ske_credentials_complete-rotation.md b/docs/stackit_ske_credentials_complete-rotation.md index 6afa588ad..404d7509f 100644 --- a/docs/stackit_ske_credentials_complete-rotation.md +++ b/docs/stackit_ske_credentials_complete-rotation.md @@ -5,15 +5,16 @@ 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. -To ensure continued access to the Kubernetes cluster, please update your kubeconfig service account to the newly created account. + 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 -After completing the rotation of credentials, you can generate a new kubeconfig file by running: - $ stackit ske kubeconfig create 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] @@ -27,8 +28,8 @@ stackit ske credentials complete-rotation CLUSTER_NAME [flags] 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 credentials complete-rotation my-cluster $ stackit ske kubeconfig create my-cluster + $ stackit ske credentials complete-rotation my-cluster ``` ### Options diff --git a/docs/stackit_ske_credentials_start-rotation.md b/docs/stackit_ske_credentials_start-rotation.md index 825f19306..b1c2cac66 100644 --- a/docs/stackit_ske_credentials_start-rotation.md +++ b/docs/stackit_ske_credentials_start-rotation.md @@ -5,6 +5,7 @@ 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. @@ -13,8 +14,11 @@ This is step 1 of a 2-step process to rotate all SKE cluster credentials. Tasks - 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] @@ -28,8 +32,8 @@ stackit ske credentials start-rotation CLUSTER_NAME [flags] 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 credentials complete-rotation my-cluster $ stackit ske kubeconfig create my-cluster + $ stackit ske credentials complete-rotation my-cluster ``` ### Options diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md index df8765849..fd73c5b76 100644 --- a/docs/stackit_ske_kubeconfig_create.md +++ b/docs/stackit_ske_kubeconfig_create.md @@ -5,7 +5,11 @@ 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] @@ -23,16 +27,16 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] 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 location - $ stackit ske kubeconfig create my-cluster --location /path/to/config + 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 in the .kube folder, in the user's home directory. -h, --help Help for "stackit ske kubeconfig create" - --location string Folder location to store the kubeconfig file. By default, the kubeconfig is created in the .kube folder, in the user's home directory. ``` ### Options inherited from parent commands diff --git a/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go index c322a9b3f..d7953efe2 100644 --- a/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go @@ -30,26 +30,28 @@ func NewCmd() *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%s\n%s\n%s\n%s\n\n%s\n%s\n%s\n%s", + 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.", - "To ensure continued access to the Kubernetes cluster, please update your kubeconfig service account to the newly created account.", "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", - "After completing the rotation of credentials, you can generate a new kubeconfig file by running:", - " $ stackit ske kubeconfig create 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"), + "$ 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 credentials complete-rotation my-cluster", "$ stackit ske kubeconfig create my-cluster", + "$ stackit ske credentials complete-rotation my-cluster", ), ), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation.go b/internal/cmd/ske/credentials/start-rotation/start_rotation.go index d56ab6d4f..da9b0cb91 100644 --- a/internal/cmd/ske/credentials/start-rotation/start_rotation.go +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation.go @@ -30,7 +30,7 @@ func NewCmd() *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%s\n%s\n%s\n%s\n%s\n%s\n%s\n\n%s\n%s", + 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", @@ -39,8 +39,12 @@ func NewCmd() *cobra.Command { " - 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"), + " $ 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( @@ -49,8 +53,8 @@ func NewCmd() *cobra.Command { 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 credentials complete-rotation my-cluster", "$ stackit ske kubeconfig create my-cluster", + "$ stackit ske credentials complete-rotation my-cluster", ), ), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go index e3aacd633..6308a729a 100644 --- a/internal/cmd/ske/kubeconfig/create/create.go +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -21,13 +21,13 @@ const ( clusterNameArg = "CLUSTER_NAME" expirationFlag = "expiration" - locationFlag = "location" + filepathFlag = "filepath" ) type inputModel struct { *globalflags.GlobalFlagModel ClusterName string - Location *string + Filepath *string ExpirationTime *string } @@ -35,9 +35,12 @@ func NewCmd() *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", clusterNameArg), Short: "Creates a kubeconfig for an SKE cluster", - Long: fmt.Sprintf("%s\n%s", + 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."), + "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( @@ -50,8 +53,8 @@ func NewCmd() *cobra.Command { `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 location`, - "$ stackit ske kubeconfig create my-cluster --location /path/to/config"), + `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() @@ -67,7 +70,7 @@ func NewCmd() *cobra.Command { } if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a kubeconfig for SKE cluster %q? This will OVERWRITE your current configuration, if it exists.", model.ClusterName) + 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 = confirm.PromptForConfirmation(cmd, prompt) if err != nil { return err @@ -90,13 +93,13 @@ func NewCmd() *cobra.Command { } var kubeconfigPath string - if model.Location == nil { - kubeconfigPath, err = skeUtils.GetDefaultKubeconfigLocation() + if model.Filepath == nil { + kubeconfigPath, err = skeUtils.GetDefaultKubeconfigPath() if err != nil { - return fmt.Errorf("get default kubeconfig location: %w", err) + return fmt.Errorf("get default kubeconfig path: %w", err) } } else { - kubeconfigPath = *model.Location + kubeconfigPath = *model.Filepath } err = skeUtils.WriteConfigFile(kubeconfigPath, *resp.Kubeconfig) @@ -115,7 +118,7 @@ func NewCmd() *cobra.Command { 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(locationFlag, "", "Folder location to store the kubeconfig file. By default, the kubeconfig is created in the .kube folder, in the user's home directory.") + 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) { @@ -126,11 +129,24 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { 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, - Location: flags.FlagToStringPointer(cmd, locationFlag), - ExpirationTime: flags.FlagToStringPointer(cmd, expirationFlag), + Filepath: flags.FlagToStringPointer(cmd, filepathFlag), + ExpirationTime: expTime, }, nil } @@ -140,12 +156,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClie payload := ske.CreateKubeconfigPayload{} if model.ExpirationTime != nil { - expirationTime, err := skeUtils.ConvertToSeconds(*model.ExpirationTime) - if err != nil { - return req, fmt.Errorf("parse expiration time: %w", err) - } - - payload.ExpirationSeconds = expirationTime + 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 index fb507ebd6..ec907dbd3 100644 --- a/internal/cmd/ske/kubeconfig/create/create_test.go +++ b/internal/cmd/ske/kubeconfig/create/create_test.go @@ -87,19 +87,19 @@ func TestParseInput(t *testing.T) { }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.ExpirationTime = utils.Ptr("30d") + model.ExpirationTime = utils.Ptr("2592000") }), }, { - description: "custom location", + description: "custom filepath", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues["location"] = "/path/to/config" + flagValues["filepath"] = "/path/to/config" }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.Location = utils.Ptr("/path/to/config") + model.Filepath = utils.Ptr("/path/to/config") }), }, { @@ -213,7 +213,7 @@ func TestBuildRequest(t *testing.T) { { description: "expiration time", model: fixtureInputModel(func(model *inputModel) { - model.ExpirationTime = utils.Ptr("30d") + model.ExpirationTime = utils.Ptr("2592000") }), expectedRequest: fixtureRequest().CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ ExpirationSeconds: utils.Ptr("2592000")}), diff --git a/internal/pkg/services/ske/utils/utils.go b/internal/pkg/services/ske/utils/utils.go index abc9e9089..901e961c3 100644 --- a/internal/pkg/services/ske/utils/utils.go +++ b/internal/pkg/services/ske/utils/utils.go @@ -199,7 +199,7 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) // 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 format: %s", timeStr) + return nil, fmt.Errorf("invalid time: %s", timeStr) } unit := timeStr[len(timeStr)-1:] @@ -207,7 +207,7 @@ func ConvertToSeconds(timeStr string) (*string, error) { valueStr := timeStr[:len(timeStr)-1] value, err := strconv.ParseUint(valueStr, 10, 64) if err != nil { - return nil, fmt.Errorf("unable to parse uint: %s", timeStr) + return nil, fmt.Errorf("invalid time value: %s", valueStr) } var multiplier uint64 @@ -228,7 +228,7 @@ func ConvertToSeconds(timeStr string) (*string, error) { case "M": multiplier = 60 * 60 * 24 * 30 default: - return nil, fmt.Errorf("invalid time format: %s", timeStr) + return nil, fmt.Errorf("invalid time unit: %s", unit) } result := uint64(value) * multiplier @@ -256,8 +256,8 @@ func WriteConfigFile(configPath, data string) error { return nil } -// GetDefaultKubeconfigLocation returns the default location for the kubeconfig file. -func GetDefaultKubeconfigLocation() (string, error) { +// 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) From a3a693a97d808e76f718c880c2ba1b9da65a72c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 3 Apr 2024 11:50:41 +0100 Subject: [PATCH 5/6] SKE Credentials Rotation: deprecate old commands (#191) * deprecate describe and rotate commands * improve deprecation messages, add docs --- docs/stackit_ske_credentials.md | 2 - docs/stackit_ske_credentials_describe.md | 41 ------------------- docs/stackit_ske_credentials_rotate.md | 38 ----------------- docs/stackit_ske_kubeconfig_create.md | 2 +- .../cmd/ske/credentials/describe/describe.go | 6 +++ internal/cmd/ske/credentials/rotate/rotate.go | 7 ++++ 6 files changed, 14 insertions(+), 82 deletions(-) delete mode 100644 docs/stackit_ske_credentials_describe.md delete mode 100644 docs/stackit_ske_credentials_rotate.md diff --git a/docs/stackit_ske_credentials.md b/docs/stackit_ske_credentials.md index e49497fb5..8b7c6f4b9 100644 --- a/docs/stackit_ske_credentials.md +++ b/docs/stackit_ske_credentials.md @@ -29,7 +29,5 @@ stackit ske credentials [flags] * [stackit ske](./stackit_ske.md) - Provides functionality for SKE * [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 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 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_describe.md b/docs/stackit_ske_credentials_describe.md deleted file mode 100644 index 712453cd4..000000000 --- a/docs/stackit_ske_credentials_describe.md +++ /dev/null @@ -1,41 +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 -``` - -### 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 ff3acb9a1..000000000 --- a/docs/stackit_ske_credentials_rotate.md +++ /dev/null @@ -1,38 +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 -``` - -### SEE ALSO - -* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials - diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md index fd73c5b76..39718071d 100644 --- a/docs/stackit_ske_kubeconfig_create.md +++ b/docs/stackit_ske_kubeconfig_create.md @@ -35,7 +35,7 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] ``` -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 in the .kube folder, in the user's home directory. + --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" ``` diff --git a/internal/cmd/ske/credentials/describe/describe.go b/internal/cmd/ske/credentials/describe/describe.go index 01917510c..4bb2c7895 100644 --- a/internal/cmd/ske/credentials/describe/describe.go +++ b/internal/cmd/ske/credentials/describe/describe.go @@ -32,6 +32,12 @@ func NewCmd() *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 my-cluster", + "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 cba8ea31e..52b95b5dc 100644 --- a/internal/cmd/ske/credentials/rotate/rotate.go +++ b/internal/cmd/ske/credentials/rotate/rotate.go @@ -32,6 +32,13 @@ func NewCmd() *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 my-cluster", + " $ stackit ske credentials complete-rotation my-cluster", + "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"`, From eddf139f2caffbb1ec691a6e4bebf1d057a22b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 3 Apr 2024 12:11:23 +0100 Subject: [PATCH 6/6] SKE Credentials Rotation: deprecate old commands improvements (#192) * remove example cluster name from deprecation message * address comments --- internal/cmd/ske/credentials/describe/describe.go | 2 +- internal/cmd/ske/credentials/rotate/rotate.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/ske/credentials/describe/describe.go b/internal/cmd/ske/credentials/describe/describe.go index 4bb2c7895..84c683a11 100644 --- a/internal/cmd/ske/credentials/describe/describe.go +++ b/internal/cmd/ske/credentials/describe/describe.go @@ -35,7 +35,7 @@ func NewCmd() *cobra.Command { 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 my-cluster", + " $ 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( diff --git a/internal/cmd/ske/credentials/rotate/rotate.go b/internal/cmd/ske/credentials/rotate/rotate.go index 52b95b5dc..57b3c8a36 100644 --- a/internal/cmd/ske/credentials/rotate/rotate.go +++ b/internal/cmd/ske/credentials/rotate/rotate.go @@ -35,8 +35,8 @@ func NewCmd() *cobra.Command { 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 my-cluster", - " $ stackit ske credentials complete-rotation my-cluster", + " $ 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(