From 3adff492b68c65d60d77fa2961cb461e894f5088 Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Mon, 20 Jan 2025 14:18:49 +0100 Subject: [PATCH] Update kubeconfig when invalid (#627) - kubeconfig expires - credentials rotation - cluster recreation Signed-off-by: Alexander Dahmen --- docs/resources/ske_kubeconfig.md | 1 + .../services/ske/kubeconfig/resource.go | 118 +++++++-- .../services/ske/kubeconfig/resource_test.go | 238 +++++++++++++++++- 3 files changed, 332 insertions(+), 25 deletions(-) diff --git a/docs/resources/ske_kubeconfig.md b/docs/resources/ske_kubeconfig.md index 15545ea1..20ef1ac0 100644 --- a/docs/resources/ske_kubeconfig.md +++ b/docs/resources/ske_kubeconfig.md @@ -35,6 +35,7 @@ resource "stackit_ske_kubeconfig" "example" { ### Read-Only +- `creation_time` (String) Date-time when the kubeconfig was created - `expires_at` (String) Timestamp when the kubeconfig expires - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`cluster_name`,`kube_config_id`". - `kube_config` (String, Sensitive) Raw short-lived admin kubeconfig. diff --git a/stackit/internal/services/ske/kubeconfig/resource.go b/stackit/internal/services/ske/kubeconfig/resource.go index fe2c451c..9772493b 100644 --- a/stackit/internal/services/ske/kubeconfig/resource.go +++ b/stackit/internal/services/ske/kubeconfig/resource.go @@ -43,6 +43,7 @@ type Model struct { Expiration types.Int64 `tfsdk:"expiration"` Refresh types.Bool `tfsdk:"refresh"` ExpiresAt types.String `tfsdk:"expires_at"` + CreationTime types.String `tfsdk:"creation_time"` } // NewKubeconfigResource is a helper function to simplify the provider implementation. @@ -108,6 +109,7 @@ func (r *kubeconfigResource) Schema(_ context.Context, _ resource.SchemaRequest, "expiration": "Expiration time of the kubeconfig, in seconds. Defaults to `3600`", "expires_at": "Timestamp when the kubeconfig expires", "refresh": "If set to true, the provider will check if the kubeconfig has expired and will generated a new valid one in-place", + "creation_time": "Date-time when the kubeconfig was created", } resp.Schema = schema.Schema{ @@ -182,6 +184,13 @@ func (r *kubeconfigResource) Schema(_ context.Context, _ resource.SchemaRequest, stringplanmodifier.UseStateForUnknown(), }, }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, }, } } @@ -230,6 +239,8 @@ func (r *kubeconfigResource) Create(ctx context.Context, req resource.CreateRequ // Read refreshes the Terraform state with the latest data. // There is no GET kubeconfig endpoint. // If the refresh field is set, Read will check the expiration date and will get a new valid kubeconfig if it has expired +// If kubeconfig creation time is before lastCompletionTime of the credentials rotation or +// before cluster creation time a new kubeconfig is created. func (r *kubeconfigResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model @@ -246,26 +257,43 @@ func (r *kubeconfigResource) Read(ctx context.Context, req resource.ReadRequest, ctx = tflog.SetField(ctx, "cluster_name", clusterName) ctx = tflog.SetField(ctx, "kube_config_id", kubeconfigUUID) - if model.Refresh.ValueBool() && !model.ExpiresAt.IsNull() { - expiresAt, err := time.Parse("2006-01-02T15:04:05Z07:00", model.ExpiresAt.ValueString()) + cluster, err := r.client.GetClusterExecute(ctx, projectId, clusterName) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("Could not get cluster(%s): %v", clusterName, err)) + return + } + + // check if kubeconfig has expired + hasExpired, err := checkHasExpired(&model, time.Now()) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("%v", err)) + return + } + + clusterRecreation, err := checkClusterRecreation(cluster, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("%v", err)) + return + } + + credentialsRotation, err := checkCredentialsRotation(cluster, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("%v", err)) + return + } + + if hasExpired || clusterRecreation || credentialsRotation { + err := r.createKubeconfig(ctx, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("Converting expiresAt field to timestamp: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("The existing kubeconfig is invalid, creating a new one: %v", err)) return } - currentTime := time.Now() - if expiresAt.Before(currentTime) { - err := r.createKubeconfig(ctx, &model) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("The existing kubeconfig is expired and the refresh field is enabled, creating a new one: %v", err)) - return - } - // Set state to fully populated data - diags = resp.State.Set(ctx, model) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } } @@ -285,7 +313,7 @@ func (r *kubeconfigResource) createKubeconfig(ctx context.Context, model *Model) } // Map response body to schema - err = mapFields(kubeconfigResp, model) + err = mapFields(kubeconfigResp, model, time.Now()) if err != nil { return fmt.Errorf("processing API payload: %w", err) } @@ -320,7 +348,7 @@ func (r *kubeconfigResource) Delete(ctx context.Context, req resource.DeleteRequ tflog.Info(ctx, "SKE kubeconfig deleted") } -func mapFields(kubeconfigResp *ske.Kubeconfig, model *Model) error { +func mapFields(kubeconfigResp *ske.Kubeconfig, model *Model, creationTime time.Time) error { if kubeconfigResp == nil { return fmt.Errorf("response is nil") } @@ -343,6 +371,8 @@ func mapFields(kubeconfigResp *ske.Kubeconfig, model *Model) error { model.Kubeconfig = types.StringPointerValue(kubeconfigResp.Kubeconfig) model.ExpiresAt = types.StringPointerValue(kubeconfigResp.ExpirationTimestamp) + // set creation time + model.CreationTime = types.StringValue(creationTime.Format(time.RFC3339)) return nil } @@ -361,3 +391,55 @@ func toCreatePayload(model *Model) (*ske.CreateKubeconfigPayload, error) { ExpirationSeconds: expirationStringPtr, }, nil } + +// helper function to check if kubecondig has expired +func checkHasExpired(model *Model, currentTime time.Time) (bool, error) { + if model.Refresh.ValueBool() && !model.ExpiresAt.IsNull() { + expiresAt, err := time.Parse(time.RFC3339, model.ExpiresAt.ValueString()) + if err != nil { + return false, fmt.Errorf("converting expiresAt field to timestamp: %w", err) + } + if expiresAt.Before(currentTime) { + return true, nil + } + } + return false, nil +} + +// helper function to check if a credentials rotation was done +func checkCredentialsRotation(cluster *ske.Cluster, model *Model) (bool, error) { + creationTime, err := time.Parse(time.RFC3339, model.CreationTime.ValueString()) + if err != nil { + return false, fmt.Errorf("converting creationTime field to timestamp: %w", err) + } + if cluster.Status.CredentialsRotation.LastCompletionTime != nil { + lastCompletionTime, err := time.Parse(time.RFC3339, *cluster.Status.CredentialsRotation.LastCompletionTime) + if err != nil { + return false, fmt.Errorf("converting LastCompletionTime to timestamp: %w", err) + } + + if creationTime.Before(lastCompletionTime) { + return true, nil + } + } + return false, nil +} + +// helper function to check if a cluster recreation was done +func checkClusterRecreation(cluster *ske.Cluster, model *Model) (bool, error) { + creationTime, err := time.Parse(time.RFC3339, model.CreationTime.ValueString()) + if err != nil { + return false, fmt.Errorf("converting creationTime field to timestamp: %w", err) + } + if cluster.Status.CreationTime != nil { + clusterCreationTime, err := time.Parse(time.RFC3339, *cluster.Status.CreationTime) + if err != nil { + return false, fmt.Errorf("converting clusterCreationTime to timestamp: %w", err) + } + + if creationTime.Before(clusterCreationTime) { + return true, nil + } + } + return false, nil +} diff --git a/stackit/internal/services/ske/kubeconfig/resource_test.go b/stackit/internal/services/ske/kubeconfig/resource_test.go index 72c1824c..c78aa201 100644 --- a/stackit/internal/services/ske/kubeconfig/resource_test.go +++ b/stackit/internal/services/ske/kubeconfig/resource_test.go @@ -2,6 +2,7 @@ package ske import ( "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -24,12 +25,13 @@ func TestMapFields(t *testing.T) { Kubeconfig: utils.Ptr("kubeconfig"), }, Model{ - ClusterName: types.StringValue("name"), - ProjectId: types.StringValue("pid"), - Kubeconfig: types.StringValue("kubeconfig"), - Expiration: types.Int64Null(), - Refresh: types.BoolNull(), - ExpiresAt: types.StringValue("2024-02-07T16:42:12Z"), + ClusterName: types.StringValue("name"), + ProjectId: types.StringValue("pid"), + Kubeconfig: types.StringValue("kubeconfig"), + Expiration: types.Int64Null(), + Refresh: types.BoolNull(), + ExpiresAt: types.StringValue("2024-02-07T16:42:12Z"), + CreationTime: types.StringValue("2024-02-05T14:40:12Z"), }, true, }, @@ -60,7 +62,8 @@ func TestMapFields(t *testing.T) { ProjectId: tt.expected.ProjectId, ClusterName: tt.expected.ClusterName, } - err := mapFields(tt.input, state) + creationTime, _ := time.Parse("2006-01-02T15:04:05Z07:00", tt.expected.CreationTime.ValueString()) + err := mapFields(tt.input, state, creationTime) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -125,3 +128,224 @@ func TestToCreatePayload(t *testing.T) { }) } } + +func TestCheckHasExpired(t *testing.T) { + tests := []struct { + description string + inputModel *Model + currentTime time.Time + expected bool + expectedError bool + }{ + { + description: "has expired", + inputModel: &Model{ + Refresh: types.BoolValue(true), + ExpiresAt: types.StringValue(time.Now().Add(-1 * time.Hour).Format(time.RFC3339)), // one hour ago + }, + currentTime: time.Now(), + expected: true, + expectedError: false, + }, + { + description: "not expired", + inputModel: &Model{ + Refresh: types.BoolValue(true), + ExpiresAt: types.StringValue(time.Now().Add(1 * time.Hour).Format(time.RFC3339)), // in one hour + }, + currentTime: time.Now(), + expected: false, + expectedError: false, + }, + { + description: "refresh is false, expired won't be checked", + inputModel: &Model{ + Refresh: types.BoolValue(false), + ExpiresAt: types.StringValue(time.Now().Add(-1 * time.Hour).Format(time.RFC3339)), // one hour ago + }, + currentTime: time.Now(), + expected: false, + expectedError: false, + }, + { + description: "invalid time", + inputModel: &Model{ + Refresh: types.BoolValue(true), + ExpiresAt: types.StringValue("invalid time"), + }, + currentTime: time.Now(), + expected: false, + expectedError: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := checkHasExpired(tt.inputModel, tt.currentTime) + if (err != nil) != tt.expectedError { + t.Errorf("checkHasExpired() error = %v, expectedError %v", err, tt.expectedError) + return + } + if got != tt.expected { + t.Errorf("checkHasExpired() = %v, expected %v", got, tt.expected) + } + }) + } +} + +func TestCheckCredentialsRotation(t *testing.T) { + tests := []struct { + description string + inputCluster *ske.Cluster + inputModel *Model + expected bool + expectedError bool + }{ + { + description: "creation time after credentials rotation", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CredentialsRotation: &ske.CredentialsRotationState{ + LastCompletionTime: utils.Ptr(time.Now().Add(-1 * time.Hour).Format(time.RFC3339)), // one hour ago + }, + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: false, + expectedError: false, + }, + { + description: "creation time before credentials rotation", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CredentialsRotation: &ske.CredentialsRotationState{ + LastCompletionTime: utils.Ptr(time.Now().Add(1 * time.Hour).Format(time.RFC3339)), + }, + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: true, + expectedError: false, + }, + { + description: "last completion time not set", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CredentialsRotation: &ske.CredentialsRotationState{ + LastCompletionTime: nil, + }, + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: false, + expectedError: false, + }, + { + description: "invalid last completion time", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CredentialsRotation: &ske.CredentialsRotationState{ + LastCompletionTime: utils.Ptr("invalid time"), + }, + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: false, + expectedError: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := checkCredentialsRotation(tt.inputCluster, tt.inputModel) + if (err != nil) != tt.expectedError { + t.Errorf("checkCredentialsRotation() error = %v, expectedError %v", err, tt.expectedError) + return + } + if got != tt.expected { + t.Errorf("checkCredentialsRotation() = %v, expected %v", got, tt.expected) + } + }) + } +} + +func TestCheckClusterRecreation(t *testing.T) { + tests := []struct { + description string + inputCluster *ske.Cluster + inputModel *Model + expected bool + expectedError bool + }{ + { + description: "cluster creation time after kubeconfig creation time", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CreationTime: utils.Ptr(time.Now().Add(-1 * time.Hour).Format(time.RFC3339)), + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: false, + expectedError: false, + }, + { + description: "cluster creation time before kubeconfig creation time", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CreationTime: utils.Ptr(time.Now().Add(1 * time.Hour).Format(time.RFC3339)), + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: true, + expectedError: false, + }, + { + description: "cluster creation time not set", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CreationTime: nil, + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: false, + expectedError: false, + }, + { + description: "invalid cluster creation time", + inputCluster: &ske.Cluster{ + Status: &ske.ClusterStatus{ + CreationTime: utils.Ptr("invalid time"), + }, + }, + inputModel: &Model{ + CreationTime: types.StringValue(time.Now().Format(time.RFC3339)), + }, + expected: false, + expectedError: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := checkClusterRecreation(tt.inputCluster, tt.inputModel) + if (err != nil) != tt.expectedError { + t.Errorf("checkClusterRecreation() error = %v, expectedError %v", err, tt.expectedError) + return + } + if got != tt.expected { + t.Errorf("checkClusterRecreation() = %v, expected %v", got, tt.expected) + } + }) + } +}