diff --git a/docs/data-sources/ske_cluster.md b/docs/data-sources/ske_cluster.md index be0a23d0..74527f16 100644 --- a/docs/data-sources/ske_cluster.md +++ b/docs/data-sources/ske_cluster.md @@ -35,7 +35,7 @@ This should be used with care since it also disables a couple of other features - `extensions` (Attributes) A single extensions block as defined below (see [below for nested schema](#nestedatt--extensions)) - `hibernations` (Attributes List) One or more hibernation block as defined below. (see [below for nested schema](#nestedatt--hibernations)) - `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`name`". -- `kube_config` (String, Sensitive) Kube config file used for connecting to the cluster +- `kube_config` (String, Sensitive, Deprecated) Kube config file used for connecting to the cluster. This field will be empty for clusters with Kubernetes v1.27+, or if you have obtained the kubeconfig or performed credentials rotation using the new process, either through the Portal or the SKE API. Use the stackit_ske_kubeconfig resource instead. For more information, see How to rotate SKE credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html). - `kubernetes_version` (String) Kubernetes version. - `kubernetes_version_used` (String) Full Kubernetes version used. For example, if `1.22` was selected, this value may result to `1.22.15` - `maintenance` (Attributes) A single maintenance block as defined below (see [below for nested schema](#nestedatt--maintenance)) diff --git a/docs/resources/ske_cluster.md b/docs/resources/ske_cluster.md index 6ce2ecfc..d109fc74 100644 --- a/docs/resources/ske_cluster.md +++ b/docs/resources/ske_cluster.md @@ -58,7 +58,7 @@ Deprecated as of Kubernetes 1.25 and later ### Read-Only - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`name`". -- `kube_config` (String, Sensitive) Kube config file used for connecting to the cluster +- `kube_config` (String, Sensitive, Deprecated) Static token kubeconfig used for connecting to the cluster. This field will be empty for clusters with Kubernetes v1.27+, or if you have obtained the kubeconfig or performed credentials rotation using the new process, either through the Portal or the SKE API. Use the stackit_ske_kubeconfig resource instead. For more information, see How to rotate SKE credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html). - `kubernetes_version_used` (String) Full Kubernetes version used. For example, if 1.22 was selected, this value may result to 1.22.15 diff --git a/docs/resources/ske_kubeconfig.md b/docs/resources/ske_kubeconfig.md new file mode 100644 index 00000000..9987c71e --- /dev/null +++ b/docs/resources/ske_kubeconfig.md @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_ske_kubeconfig Resource - stackit" +subcategory: "" +description: |- + SKE kubeconfig resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_ske_kubeconfig (Resource) + +SKE kubeconfig resource schema. Must have a `region` specified in the provider configuration. + + + + +## Schema + +### Required + +- `cluster_name` (String) Name of the SKE cluster. +- `project_id` (String) STACKIT project ID to which the cluster is associated. + +### Optional + +- `expiration` (Number) Expiration time of the kubeconfig, in seconds. +- `refresh` (Boolean) If set to true, the provider will check if the kubeconfig has expired and will generated a new valid one in-place + +### Read-Only + +- `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. +- `kube_config_id` (String) Internally generated UUID to identify a kubeconfig resource in Terraform, since the SKE API doesnt return a kubeconfig identifier diff --git a/stackit/internal/services/ske/cluster/datasource.go b/stackit/internal/services/ske/cluster/datasource.go index 1b850d36..c717cc55 100644 --- a/stackit/internal/services/ske/cluster/datasource.go +++ b/stackit/internal/services/ske/cluster/datasource.go @@ -3,6 +3,7 @@ package ske import ( "context" "fmt" + "net/http" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" @@ -11,9 +12,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/ske" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + "golang.org/x/mod/semver" ) // Ensure the implementation satisfies the expected interfaces. @@ -269,9 +272,10 @@ func (r *clusterDataSource) Schema(_ context.Context, _ datasource.SchemaRequest }, }, "kube_config": schema.StringAttribute{ - Description: "Kube config file used for connecting to the cluster", - Sensitive: true, - Computed: true, + Description: "Kube config file used for connecting to the cluster. This field will be empty for clusters with Kubernetes v1.27+, or if you have obtained the kubeconfig or performed credentials rotation using the new process, either through the Portal or the SKE API. Use the stackit_ske_kubeconfig resource instead. For more information, see How to rotate SKE credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html).", + Sensitive: true, + Computed: true, + DeprecationMessage: "This field will be empty for clusters with Kubernetes v1.27+, or if you have obtained the kubeconfig or performed credentials rotation using the new process, either through the Portal or the SKE API. Use the stackit_ske_kubeconfig resource instead. For more information, see How to rotate SKE credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html).", }, }, } @@ -301,7 +305,12 @@ func (r *clusterDataSource) Read(ctx context.Context, req datasource.ReadRequest core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading cluster", fmt.Sprintf("Processing API payload: %v", err)) return } - r.getCredential(ctx, &diags, &state) + // Handle credential + err = r.getCredential(ctx, &resp.Diagnostics, &state) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading cluster", fmt.Sprintf("Getting credential: %v", err)) + return + } // Set refreshed state diags = resp.State.Set(ctx, state) resp.Diagnostics.Append(diags...) @@ -311,12 +320,29 @@ func (r *clusterDataSource) Read(ctx context.Context, req datasource.ReadRequest tflog.Info(ctx, "SKE cluster read") } -func (r *clusterDataSource) getCredential(ctx context.Context, diags *diag.Diagnostics, model *Model) { +func (r *clusterDataSource) getCredential(ctx context.Context, diags *diag.Diagnostics, model *Model) error { c := r.client + // for kubernetes with version >= 1.27, the deprecated endpoint will not work, so we set kubeconfig to nil + if semver.Compare(fmt.Sprintf("v%s", model.KubernetesVersion.ValueString()), "v1.27") >= 0 { + core.LogAndAddWarning(ctx, diags, "The kubelogin field is set to null", "Kubernetes version is 1.27 or higher, you must use the stackit_ske_kubeconfig resource instead.") + model.KubeConfig = types.StringPointerValue(nil) + return nil + } res, err := c.GetCredentials(ctx, model.ProjectId.ValueString(), model.Name.ValueString()).Execute() if err != nil { - diags.AddError("failed fetching cluster credentials for data source", err.Error()) - return + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if !ok { + return fmt.Errorf("fetch cluster credentials: could not convert error to oapierror.GenericOpenAPIError") + } + if oapiErr.StatusCode == http.StatusBadRequest { + // deprecated endpoint will return 400 if the new endpoints have been used + // if that's the case, we set the field to null + core.LogAndAddWarning(ctx, diags, "The kubelogin field is set to null", "The call to GetCredentials failed, which means the new credentials rotation flow might already been triggered for this cluster. If you are already using the stackit_ske_kubeconfig resource you can ignore this warning. If not, you must start using it.") + model.KubeConfig = types.StringPointerValue(nil) + return nil + } + return fmt.Errorf("fetching cluster credentials: %w", err) } model.KubeConfig = types.StringPointerValue(res.Kubeconfig) + return nil } diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index bf863c69..ae0db2be 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -3,6 +3,7 @@ package ske import ( "context" "fmt" + "net/http" "regexp" "strings" "time" @@ -27,6 +28,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/ske" "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" @@ -497,9 +499,10 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, "kube_config": schema.StringAttribute{ - Description: "Kube config file used for connecting to the cluster", - Sensitive: true, - Computed: true, + Description: "Static token kubeconfig used for connecting to the cluster. This field will be empty for clusters with Kubernetes v1.27+, or if you have obtained the kubeconfig or performed credentials rotation using the new process, either through the Portal or the SKE API. Use the stackit_ske_kubeconfig resource instead. For more information, see How to rotate SKE credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html).", + Sensitive: true, + Computed: true, + DeprecationMessage: "This field will be empty for clusters with Kubernetes v1.27+, or if you have obtained the kubeconfig or performed credentials rotation using the new process, either through the Portal or the SKE API. Use the stackit_ske_kubeconfig resource instead. For more information, see How to rotate SKE credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html).", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -648,17 +651,34 @@ func (r *clusterResource) createOrUpdateCluster(ctx context.Context, diags *diag } // Handle credential - err = r.getCredential(ctx, model) + err = r.getCredential(ctx, diags, model) if err != nil { core.LogAndAddError(ctx, diags, "Error creating/updating cluster", fmt.Sprintf("Getting credential: %v", err)) return } } -func (r *clusterResource) getCredential(ctx context.Context, model *Model) error { +func (r *clusterResource) getCredential(ctx context.Context, diags *diag.Diagnostics, model *Model) error { c := r.client + // for kubernetes with version >= 1.27, the deprecated endpoint will not work, so we set kubeconfig to nil + if semver.Compare(fmt.Sprintf("v%s", model.KubernetesVersion.ValueString()), "v1.27") >= 0 { + core.LogAndAddWarning(ctx, diags, "The kubelogin field is set to null", "Kubernetes version is 1.27 or higher, you must use the stackit_ske_kubeconfig resource instead.") + model.KubeConfig = types.StringPointerValue(nil) + return nil + } res, err := c.GetCredentials(ctx, model.ProjectId.ValueString(), model.Name.ValueString()).Execute() if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if !ok { + return fmt.Errorf("fetch cluster credentials: could not convert error to oapierror.GenericOpenAPIError") + } + if oapiErr.StatusCode == http.StatusBadRequest { + // deprecated endpoint will return 400 if the new endpoints have been used + // if that's the case, we set the field to null + core.LogAndAddWarning(ctx, diags, "The kubelogin field is set to null", "The call to GetCredentials failed, which means the new credentials rotation flow might already been triggered for this cluster. If you are already using the stackit_ske_kubeconfig resource you can ignore this warning. If not, you must start using it.") + model.KubeConfig = types.StringPointerValue(nil) + return nil + } return fmt.Errorf("fetching cluster credentials: %w", err) } model.KubeConfig = types.StringPointerValue(res.Kubeconfig) diff --git a/stackit/internal/services/ske/kubeconfig/resource.go b/stackit/internal/services/ske/kubeconfig/resource.go new file mode 100644 index 00000000..a36b3666 --- /dev/null +++ b/stackit/internal/services/ske/kubeconfig/resource.go @@ -0,0 +1,361 @@ +package ske + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &kubeconfigResource{} + _ resource.ResourceWithConfigure = &kubeconfigResource{} + _ resource.ResourceWithModifyPlan = &kubeconfigResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ClusterName types.String `tfsdk:"cluster_name"` + ProjectId types.String `tfsdk:"project_id"` + KubeconfigId types.String `tfsdk:"kube_config_id"` // uuid generated internally because kubeconfig has no identifier + Kubeconfig types.String `tfsdk:"kube_config"` + Expiration types.Int64 `tfsdk:"expiration"` + Refresh types.Bool `tfsdk:"refresh"` + ExpiresAt types.String `tfsdk:"expires_at"` +} + +// NewKubeconfigResource is a helper function to simplify the provider implementation. +func NewKubeconfigResource() resource.Resource { + return &kubeconfigResource{} +} + +// kubeconfigResource is the resource implementation. +type kubeconfigResource struct { + client *ske.APIClient +} + +// Metadata returns the resource type name. +func (r *kubeconfigResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ske_kubeconfig" +} + +// Configure adds the provider configured client to the resource. +func (r *kubeconfigResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + var apiClient *ske.APIClient + var err error + if providerData.SKECustomEndpoint != "" { + apiClient, err = ske.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.SKECustomEndpoint), + ) + } else { + apiClient, err = ske.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "SKE kubeconfig client configured") +} + +// Schema defines the schema for the resource. +func (r *kubeconfigResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "SKE kubeconfig resource schema. Must have a `region` specified in the provider configuration.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`cluster_name`,`kube_config_id`\".", + "kube_config_id": "Internally generated UUID to identify a kubeconfig resource in Terraform, since the SKE API doesnt return a kubeconfig identifier", + "cluster_name": "Name of the SKE cluster.", + "project_id": "STACKIT project ID to which the cluster is associated.", + "kube_config": "Raw short-lived admin kubeconfig.", + "expiration": "Expiration time of the kubeconfig, in seconds.", + "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", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "kube_config_id": schema.StringAttribute{ + Description: descriptions["kube_config_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "cluster_name": schema.StringAttribute{ + Description: descriptions["cluster_name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "expiration": schema.Int64Attribute{ + Description: descriptions["expiration"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + int64planmodifier.UseStateForUnknown(), + }, + }, + "refresh": schema.BoolAttribute{ + Description: descriptions["refresh"], + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "kube_config": schema.StringAttribute{ + Description: descriptions["kube_config"], + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "expires_at": schema.StringAttribute{ + Description: descriptions["expires_at"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// ModifyPlan will be called in the Plan phase and will check if the plan is a creation of the resource +// If so, show warning related to deprecated credentials endpoints +func (r *kubeconfigResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + if req.State.Raw.IsNull() { + // Planned to create a kubeconfig + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Planned to create kubeconfig", "Once this resource is created, you will no longer be able to use the deprecated credentials endpoints and the kube_config field on the cluster resource will be empty for this cluster. For more info check How to Rotate SKE Credentials (https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html)") + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *kubeconfigResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + clusterName := model.ClusterName.ValueString() + kubeconfigUUID := uuid.New().String() + + model.KubeconfigId = types.StringValue(kubeconfigUUID) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "cluster_name", clusterName) + ctx = tflog.SetField(ctx, "kube_config_id", kubeconfigUUID) + + err := r.createKubeconfig(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Creating kubeconfig: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "SKE kubeconfig created") +} + +// 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 +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 + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + clusterName := model.ClusterName.ValueString() + kubeconfigUUID := model.KubeconfigId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + 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()) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading kubeconfig", fmt.Sprintf("Converting expiresAt field to timestamp: %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 + } + } + } + + tflog.Info(ctx, "SKE kubeconfig read") +} + +func (r *kubeconfigResource) createKubeconfig(ctx context.Context, model *Model) error { + // Generate API request body from model + payload, err := toCreatePayload(model) + if err != nil { + return fmt.Errorf("creating API payload: %w", err) + } + // Create new kubeconfig + kubeconfigResp, err := r.client.CreateKubeconfig(ctx, model.ProjectId.ValueString(), model.ClusterName.ValueString()).CreateKubeconfigPayload(*payload).Execute() + if err != nil { + return fmt.Errorf("calling API: %w", err) + } + + // Map response body to schema + err = mapFields(kubeconfigResp, model) + if err != nil { + return fmt.Errorf("processing API payload: %w", err) + } + return nil +} + +func (r *kubeconfigResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update shouldn't be called + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating kubeconfig", "Kubeconfig can't be updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *kubeconfigResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Deleting kubeconfig", "Deleted this resource will only remove the values from the terraform state, it will not trigger a deletion or revoke of the actual kubeconfig as this is not supported by the SKE API. The kubeconfig will still be valid until it expires.") + + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + clusterName := model.ClusterName.ValueString() + kubeconfigUUID := model.KubeconfigId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "cluster_name", clusterName) + ctx = tflog.SetField(ctx, "kube_config_id", kubeconfigUUID) + + // kubeconfig is deleted automatically from the state + tflog.Info(ctx, "SKE kubeconfig deleted") +} + +func mapFields(kubeconfigResp *ske.Kubeconfig, model *Model) error { + if kubeconfigResp == nil { + return fmt.Errorf("response is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + idParts := []string{ + model.ProjectId.ValueString(), + model.ClusterName.ValueString(), + model.KubeconfigId.ValueString(), + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + if kubeconfigResp.Kubeconfig == nil { + return fmt.Errorf("kubeconfig not present") + } + + model.Kubeconfig = types.StringPointerValue(kubeconfigResp.Kubeconfig) + model.ExpiresAt = types.StringPointerValue(kubeconfigResp.ExpirationTimestamp) + return nil +} + +func toCreatePayload(model *Model) (*ske.CreateKubeconfigPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + expiration := conversion.Int64ValueToPointer(model.Expiration) + var expirationStringPtr *string + if expiration != nil { + expirationStringPtr = utils.Ptr(strconv.FormatInt(*expiration, 10)) + } + + return &ske.CreateKubeconfigPayload{ + ExpirationSeconds: expirationStringPtr, + }, nil +} diff --git a/stackit/internal/services/ske/kubeconfig/resource_test.go b/stackit/internal/services/ske/kubeconfig/resource_test.go new file mode 100644 index 00000000..72c1824c --- /dev/null +++ b/stackit/internal/services/ske/kubeconfig/resource_test.go @@ -0,0 +1,127 @@ +package ske + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *ske.Kubeconfig + expected Model + isValid bool + }{ + { + "simple_values", + &ske.Kubeconfig{ + ExpirationTimestamp: utils.Ptr("2024-02-07T16:42:12Z"), + 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"), + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "empty_kubeconfig", + &ske.Kubeconfig{}, + Model{}, + false, + }, + { + "no_kubeconfig_field", + &ske.Kubeconfig{ + ExpirationTimestamp: utils.Ptr("2024-02-07T16:42:12Z"), + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + ClusterName: tt.expected.ClusterName, + } + err := mapFields(tt.input, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(state, &tt.expected, cmpopts.IgnoreFields(Model{}, "Id")) // Id includes a random uuid + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *ske.CreateKubeconfigPayload + isValid bool + }{ + { + "default_values", + &Model{}, + &ske.CreateKubeconfigPayload{}, + true, + }, + { + "simple_values", + &Model{ + Expiration: types.Int64Value(3600), + }, + &ske.CreateKubeconfigPayload{ + ExpirationSeconds: utils.Ptr("3600"), + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index 5f6324f3..47a9e341 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -25,16 +25,15 @@ var clusterResource = map[string]string{ "project_id": testutil.ProjectId, "name": fmt.Sprintf("cl-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)), "name_min": fmt.Sprintf("cl-min-%s", acctest.RandStringFromCharSet(3, acctest.CharSetAlphaNum)), - "kubernetes_version": "1.24", - "kubernetes_version_used": "1.24.17", - "kubernetes_version_new": "1.25", - "kubernetes_version_used_new": "1.25.15", - "allowPrivilegedContainers": "true", + "kubernetes_version": "1.25", + "kubernetes_version_used": "1.25.16", + "kubernetes_version_new": "1.26", + "kubernetes_version_used_new": "1.26.13", "nodepool_name": "np-acc-test", "nodepool_name_min": "np-acc-min-test", "nodepool_machine_type": "b1.2", - "nodepool_os_version": "3602.2.1", - "nodepool_os_version_min": "3602.2.1", + "nodepool_os_version": "3602.2.2", + "nodepool_os_version_min": "3602.2.2", "nodepool_os_name": "flatcar", "nodepool_minimum": "2", "nodepool_maximum": "3", @@ -61,17 +60,10 @@ var clusterResource = map[string]string{ "maintenance_start": "01:23:45Z", "maintenance_end": "05:00:00+02:00", "maintenance_end_new": "03:03:03+00:00", + "kubeconfig_expiration": "3600", } -func getConfig(version string, apc *bool, maintenanceEnd *string) string { - apcConfig := "" - if apc != nil { - if *apc { - apcConfig = "allow_privileged_containers = true" - } else { - apcConfig = "allow_privileged_containers = false" - } - } +func getConfig(version string, maintenanceEnd *string) string { maintenanceEndTF := clusterResource["maintenance_end"] if maintenanceEnd != nil { maintenanceEndTF = *maintenanceEnd @@ -87,7 +79,6 @@ func getConfig(version string, apc *bool, maintenanceEnd *string) string { project_id = stackit_ske_project.project.project_id name = "%s" kubernetes_version = "%s" - %s node_pools = [{ name = "%s" machine_type = "%s" @@ -133,6 +124,12 @@ func getConfig(version string, apc *bool, maintenanceEnd *string) string { } } + resource "stackit_ske_kubeconfig" "kubeconfig" { + project_id = stackit_ske_project.project.project_id + cluster_name = stackit_ske_cluster.cluster.name + expiration = "%s" + } + resource "stackit_ske_cluster" "cluster_min" { project_id = stackit_ske_project.project.project_id name = "%s" @@ -157,7 +154,6 @@ func getConfig(version string, apc *bool, maintenanceEnd *string) string { projectResource["project_id"], clusterResource["name"], version, - apcConfig, clusterResource["nodepool_name"], clusterResource["nodepool_machine_type"], clusterResource["nodepool_minimum"], @@ -187,6 +183,9 @@ func getConfig(version string, apc *bool, maintenanceEnd *string) string { clusterResource["maintenance_start"], maintenanceEndTF, + // Kubeconfig + clusterResource["kubeconfig_expiration"], + // Minimal clusterResource["name_min"], clusterResource["kubernetes_version_new"], @@ -211,7 +210,7 @@ func TestAccSKE(t *testing.T) { // 1) Creation { - Config: getConfig(clusterResource["kubernetes_version"], utils.Ptr(true), nil), + Config: getConfig(clusterResource["kubernetes_version"], nil), Check: resource.ComposeAggregateTestCheckFunc( // project data resource.TestCheckResourceAttr("stackit_ske_project.project", "project_id", projectResource["project_id"]), @@ -223,7 +222,6 @@ func TestAccSKE(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "name", clusterResource["name"]), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "kubernetes_version", clusterResource["kubernetes_version"]), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "kubernetes_version_used", clusterResource["kubernetes_version_used"]), - resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "allow_privileged_containers", clusterResource["allowPrivilegedContainers"]), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "node_pools.0.name", clusterResource["nodepool_name"]), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "node_pools.0.availability_zones.#", "1"), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "node_pools.0.availability_zones.0", clusterResource["nodepool_zone"]), @@ -255,9 +253,22 @@ func TestAccSKE(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "maintenance.enable_machine_image_version_updates", clusterResource["maintenance_enable_machine_image_version_updates"]), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "maintenance.start", clusterResource["maintenance_start"]), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "maintenance.end", clusterResource["maintenance_end"]), - resource.TestCheckResourceAttrSet("stackit_ske_cluster.cluster", "kube_config"), + // Kubeconfig + + resource.TestCheckResourceAttrPair( + "stackit_ske_kubeconfig.kubeconfig", "project_id", + "stackit_ske_cluster.cluster", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_ske_kubeconfig.kubeconfig", "cluster_name", + "stackit_ske_cluster.cluster", "name", + ), + resource.TestCheckResourceAttr("stackit_ske_kubeconfig.kubeconfig", "expiration", clusterResource["kubeconfig_expiration"]), + resource.TestCheckResourceAttrSet("stackit_ske_kubeconfig.kubeconfig", "kube_config"), + resource.TestCheckResourceAttrSet("stackit_ske_kubeconfig.kubeconfig", "expires_at"), + // Minimal cluster resource.TestCheckResourceAttrPair( "stackit_ske_project.project", "project_id", @@ -314,7 +325,7 @@ func TestAccSKE(t *testing.T) { } `, - getConfig(clusterResource["kubernetes_version"], utils.Ptr(true), nil), + getConfig(clusterResource["kubernetes_version"], nil), projectResource["project_id"], clusterResource["project_id"], clusterResource["name"], @@ -334,7 +345,6 @@ func TestAccSKE(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "name", clusterResource["name"]), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "kubernetes_version", clusterResource["kubernetes_version"]), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "kubernetes_version_used", clusterResource["kubernetes_version_used"]), - resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "allow_privileged_containers", clusterResource["allowPrivilegedContainers"]), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "node_pools.0.name", clusterResource["nodepool_name"]), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "node_pools.0.availability_zones.#", "1"), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "node_pools.0.availability_zones.0", clusterResource["nodepool_zone"]), @@ -366,7 +376,7 @@ func TestAccSKE(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "maintenance.start", clusterResource["maintenance_start"]), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "maintenance.end", clusterResource["maintenance_end"]), - resource.TestCheckResourceAttrSet("data.stackit_ske_cluster.cluster", "kube_config"), + resource.TestCheckNoResourceAttr("data.stackit_ske_cluster.cluster", "kube_config"), // when using the kubeconfig resource, the kubeconfig field becomes null // Minimal cluster resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster_min", "name", clusterResource["name_min"]), @@ -457,7 +467,7 @@ func TestAccSKE(t *testing.T) { }, // 6) Update kubernetes version and maximum { - Config: getConfig(clusterResource["kubernetes_version_new"], nil, utils.Ptr(clusterResource["maintenance_end_new"])), + Config: getConfig(clusterResource["kubernetes_version_new"], utils.Ptr(clusterResource["maintenance_end_new"])), Check: resource.ComposeAggregateTestCheckFunc( // cluster data resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "project_id", clusterResource["project_id"]), diff --git a/stackit/provider.go b/stackit/provider.go index abb548e7..8124cba5 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -39,6 +39,7 @@ import ( secretsManagerInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/instance" secretsManagerUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/user" skeCluster "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/cluster" + skeKubeconfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/kubeconfig" skeProject "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/project" sdkauth "github.com/stackitcloud/stackit-sdk-go/core/auth" @@ -405,5 +406,6 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { secretsManagerUser.NewUserResource, skeProject.NewProjectResource, skeCluster.NewClusterResource, + skeKubeconfig.NewKubeconfigResource, } }