From dc5a5e845a7edcfdff822aafc0505bf49014d194 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 8 May 2026 10:18:40 +0200 Subject: [PATCH 1/3] feat(ske): add ephemeral ske kubeconfig Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/ske_kubeconfig.md | 52 +++++ .../ephemeral-resource.tf | 18 ++ .../access_token/access_token_acc_test.go | 3 +- .../ske/kubeconfig/ephemeral_resource.go | 181 ++++++++++++++++++ .../ske/kubeconfig/ephemeral_resource_test.go | 98 ++++++++++ stackit/internal/services/ske/ske_acc_test.go | 30 ++- .../services/ske/testdata/resource-max.tf | 16 +- .../services/ske/testdata/resource-min.tf | 11 ++ stackit/provider.go | 1 + 9 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 docs/ephemeral-resources/ske_kubeconfig.md create mode 100644 examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf create mode 100644 stackit/internal/services/ske/kubeconfig/ephemeral_resource.go create mode 100644 stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go diff --git a/docs/ephemeral-resources/ske_kubeconfig.md b/docs/ephemeral-resources/ske_kubeconfig.md new file mode 100644 index 000000000..5d73ff3d2 --- /dev/null +++ b/docs/ephemeral-resources/ske_kubeconfig.md @@ -0,0 +1,52 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_ske_kubeconfig Ephemeral Resource - stackit" +subcategory: "" +description: |- + Ephemeral resource that generates a short-lived SKE kubeconfig. A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. +--- + +# stackit_ske_kubeconfig (Ephemeral Resource) + +Ephemeral resource that generates a short-lived SKE kubeconfig. A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. + +## Example Usage + +```terraform +resource "stackit_ske_cluster" "example" { + # ... cluster configuration ... +} + +# We use the cluster ID ternary to force evaluation during the Apply phase. +# Unlike managed resources, ephemeral resources evaluate during the Plan phase +# if inputs are known, which would trigger a 404 before the cluster exists. +ephemeral "stackit_ske_kubeconfig" "example" { + project_id = stackit_ske_cluster.example.project_id + cluster_name = stackit_ske_cluster.example.id != "" ? stackit_ske_cluster.example.name : "" +} + +provider "kubernetes" { + host = yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.server + client_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-certificate-data) + client_key = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-key-data) + cluster_ca_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.certificate-authority-data) +} +``` + + +## 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. Must be between `600` (10m) and `14400` (4h). Defaults to `1800` (30m) for optimal security during Terraform operations, which is more restrictive than the API default of `3600` (1h). +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `expires_at` (String) Timestamp when the kubeconfig expires. +- `kube_config` (String, Sensitive) Raw short-lived admin kubeconfig. diff --git a/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf b/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf new file mode 100644 index 000000000..c7b531d53 --- /dev/null +++ b/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf @@ -0,0 +1,18 @@ +resource "stackit_ske_cluster" "example" { + # ... cluster configuration ... +} + +# We use the cluster ID ternary to force evaluation during the Apply phase. +# Unlike managed resources, ephemeral resources evaluate during the Plan phase +# if inputs are known, which would trigger a 404 before the cluster exists. +ephemeral "stackit_ske_kubeconfig" "example" { + project_id = stackit_ske_cluster.example.project_id + cluster_name = stackit_ske_cluster.example.id != "" ? stackit_ske_cluster.example.name : "" +} + +provider "kubernetes" { + host = yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.server + client_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-certificate-data) + client_key = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-key-data) + cluster_ca_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.certificate-authority-data) +} diff --git a/stackit/internal/services/access_token/access_token_acc_test.go b/stackit/internal/services/access_token/access_token_acc_test.go index 47a5ee049..529e8fd92 100644 --- a/stackit/internal/services/access_token/access_token_acc_test.go +++ b/stackit/internal/services/access_token/access_token_acc_test.go @@ -33,6 +33,7 @@ func TestAccEphemeralAccessToken(t *testing.T) { Config: ephemeralResourceConfig, ConfigVariables: testConfigVars, ConfigStateChecks: []statecheck.StateCheck{ + // Check that the output is not null statecheck.ExpectKnownValue( "echo.example", tfjsonpath.New("data").AtMapKey("access_token"), @@ -42,7 +43,7 @@ func TestAccEphemeralAccessToken(t *testing.T) { statecheck.ExpectKnownValue( "echo.example", tfjsonpath.New("data").AtMapKey("access_token"), - knownvalue.StringRegexp(regexp.MustCompile(`^ey`)), + knownvalue.StringRegexp(regexp.MustCompile("^ey")), ), }, }, diff --git a/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go b/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go new file mode 100644 index 000000000..3716517df --- /dev/null +++ b/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go @@ -0,0 +1,181 @@ +package ske + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + skeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +const ( + defaultKubeconfigExpiration = 1800 +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ ephemeral.EphemeralResource = &kubeconfigEphemeralResource{} + _ ephemeral.EphemeralResourceWithConfigure = &kubeconfigEphemeralResource{} +) + +// NewKubeconfigEphemeralResource is a helper function to simplify the provider implementation. +func NewKubeconfigEphemeralResource() ephemeral.EphemeralResource { + return &kubeconfigEphemeralResource{} +} + +// kubeconfigEphemeralResource is the ephemeral resource implementation. +type kubeconfigEphemeralResource struct { + client *ske.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (e *kubeconfigEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ske_kubeconfig" +} + +// Configure adds the provider configured client to the resource. +func (e *kubeconfigEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + ephemeralProviderData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + e.providerData = ephemeralProviderData.ProviderData + e.client = skeUtils.ConfigureClient(ctx, &e.providerData, &resp.Diagnostics) + + tflog.Info(ctx, "SKE kubeconfig client configured") +} + +// ephemeralModel is the model for the ephemeral resource. +type ephemeralModel struct { + ClusterName types.String `tfsdk:"cluster_name"` + ProjectId types.String `tfsdk:"project_id"` + Expiration types.Int64 `tfsdk:"expiration"` + Region types.String `tfsdk:"region"` + Kubeconfig types.String `tfsdk:"kube_config"` + ExpiresAt types.String `tfsdk:"expires_at"` +} + +// Schema defines the schema for the ephemeral resource. +func (e *kubeconfigEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + description := "Ephemeral resource that generates a short-lived SKE kubeconfig. " + + "A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation." + + resp.Schema = schema.Schema{ + Description: description, + Attributes: map[string]schema.Attribute{ + "cluster_name": schema.StringAttribute{ + Description: "Name of the SKE cluster.", + Required: true, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the cluster is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "expiration": schema.Int64Attribute{ + Description: "Expiration time of the kubeconfig in seconds. Must be between `600` (10m) and `14400` (4h). " + + "Defaults to `1800` (30m) for optimal security during Terraform operations, which is more restrictive than the API default of `3600` (1h).", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(600), + int64validator.AtMost(14400), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: "The resource region. If not defined, the provider region is used.", + }, + "kube_config": schema.StringAttribute{ + Description: "Raw short-lived admin kubeconfig.", + Computed: true, + Sensitive: true, + }, + "expires_at": schema.StringAttribute{ + Description: "Timestamp when the kubeconfig expires.", + Computed: true, + }, + }, + } +} + +// Open creates the kubeconfig and sets the result. +func (e *kubeconfigEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var model ephemeralModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + clusterName := model.ClusterName.ValueString() + region := e.providerData.GetRegionWithOverride(model.Region) + + // Kubeconfig only needs to be valid for the duration of the Terraform operation. + // Defaulted to 1800s (30m) for better security than the API default (3600s). + expiration := conversion.Int64ValueToPointer(model.Expiration) + if expiration == nil { + expiration = new(int64) + *expiration = defaultKubeconfigExpiration + } + + kubeconfigResp, err := getKubeconfig(ctx, e.client, projectId, region, clusterName, expiration) + + ctx = core.LogResponse(ctx) + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Calling SKE API: %v", err)) + return + } + + if kubeconfigResp == nil || kubeconfigResp.Kubeconfig == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", "API returned an empty response") + return + } + + model.Kubeconfig = types.StringPointerValue(kubeconfigResp.Kubeconfig) + model.ExpiresAt = types.StringValue(kubeconfigResp.ExpirationTimestamp.Format(time.RFC3339)) + model.Region = types.StringValue(region) + + resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) + tflog.Info(ctx, "SKE kubeconfig opened") +} + +// getKubeconfig initializes the API call to generate a new kubeconfig +func getKubeconfig(ctx context.Context, client *ske.APIClient, projectId, region, clusterName string, expiration *int64) (*ske.Kubeconfig, error) { + var expirationStringPtr *string + if expiration != nil { + expirationStringPtr = new(string) + *expirationStringPtr = strconv.FormatInt(*expiration, 10) + } + + payload := ske.CreateKubeconfigPayload{ + ExpirationSeconds: expirationStringPtr, + } + + return client.DefaultAPI.CreateKubeconfig(ctx, projectId, region, clusterName).CreateKubeconfigPayload(payload).Execute() +} diff --git a/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go b/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go new file mode 100644 index 000000000..1f8974b64 --- /dev/null +++ b/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go @@ -0,0 +1,98 @@ +package ske + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-sdk-go/core/config" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +func TestGetKubeconfig(t *testing.T) { + const ( + projectId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + clusterName = "cluster" + region = "eu01" + kubeconfig = "mock-kubeconfig" + ) + expirationTime := time.Now().Add(time.Hour).Truncate(time.Second) + + tests := []struct { + description string + expiration *int64 + mockResponse *ske.Kubeconfig + mockStatusCode int + expectError bool + }{ + { + description: "success", + expiration: nil, + mockResponse: &ske.Kubeconfig{ + Kubeconfig: &[]string{kubeconfig}[0], + ExpirationTimestamp: &expirationTime, + AdditionalProperties: make(map[string]any), + }, + mockStatusCode: http.StatusOK, + expectError: false, + }, + { + description: "success with expiration", + expiration: &[]int64{3600}[0], + mockResponse: &ske.Kubeconfig{ + Kubeconfig: &[]string{kubeconfig}[0], + ExpirationTimestamp: &expirationTime, + AdditionalProperties: make(map[string]any), + }, + mockStatusCode: http.StatusOK, + expectError: false, + }, + { + description: "api error", + mockStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := fmt.Sprintf("/v2/projects/%s/regions/%s/clusters/%s/kubeconfig", projectId, region, clusterName) + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != nil { + _ = json.NewEncoder(w).Encode(tt.mockResponse) + } + })) + defer server.Close() + + cfg, err := ske.NewAPIClient( + config.WithEndpoint(server.URL), + config.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to create SKE client: %v", err) + } + + resp, err := getKubeconfig(context.Background(), cfg, projectId, region, clusterName, tt.expiration) + + if (err != nil) != tt.expectError { + t.Fatalf("getKubeconfig() error = %v, expectError %v", err, tt.expectError) + } + + if !tt.expectError { + if diff := cmp.Diff(resp, tt.mockResponse); diff != "" { + t.Errorf("Response mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index 2776c133b..c67c768b9 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -10,8 +10,12 @@ import ( "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" "github.com/stackitcloud/stackit-sdk-go/core/utils" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" @@ -119,10 +123,12 @@ func configVarsMaxUpdated() config.Variables { func TestAccSKEMin(t *testing.T) { resource.Test(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: testutil.TestEphemeralAccProtoV6ProviderFactories, CheckDestroy: testAccCheckSKEDestroy, Steps: []resource.TestStep{ - // 1) Creation { Config: testutil.NewConfigBuilder().BuildProviderConfig() + "\n" + resourceMin, @@ -163,6 +169,13 @@ func TestAccSKEMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.enabled", "false"), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.type", "stackit"), ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.example", + tfjsonpath.New("data"), + knownvalue.NotNull(), + ), + }, }, // 2) Data source { @@ -266,10 +279,12 @@ func TestAccSKEMin(t *testing.T) { func TestAccSKEMax(t *testing.T) { resource.Test(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: testutil.TestEphemeralAccProtoV6ProviderFactories, CheckDestroy: testAccCheckSKEDestroy, Steps: []resource.TestStep{ - // 1) Creation { Config: testutil.NewConfigBuilder().BuildProviderConfig() + "\n" + resourceMax, @@ -347,6 +362,13 @@ func TestAccSKEMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_kubeconfig.kubeconfig", "refresh_before", testutil.ConvertConfigVariable(testConfigVarsMax["refresh_before"])), resource.TestCheckResourceAttrSet("stackit_ske_kubeconfig.kubeconfig", "expires_at"), ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.example", + tfjsonpath.New("data"), + knownvalue.NotNull(), + ), + }, }, // 2) Data source { diff --git a/stackit/internal/services/ske/testdata/resource-max.tf b/stackit/internal/services/ske/testdata/resource-max.tf index 92377aa0e..176eca925 100644 --- a/stackit/internal/services/ske/testdata/resource-max.tf +++ b/stackit/internal/services/ske/testdata/resource-max.tf @@ -113,6 +113,7 @@ resource "stackit_ske_kubeconfig" "kubeconfig" { expiration = var.expiration refresh = var.refresh refresh_before = var.refresh_before + region = var.region } data "stackit_ske_cluster" "cluster" { @@ -120,10 +121,23 @@ data "stackit_ske_cluster" "cluster" { name = stackit_ske_cluster.cluster.name } - resource "stackit_dns_zone" "dns-zone" { project_id = var.project_id name = var.dns_zone_name dns_name = var.dns_name } +ephemeral "stackit_ske_kubeconfig" "ephemeral_kubeconfig" { + project_id = var.project_id + # cluster_name is unknown during the plan phase because stackit_ske_cluster.cluster.id is computed. + # This forces Terraform to defer the Open call until the Apply phase, after the cluster is ready. + cluster_name = stackit_ske_cluster.cluster.id != "" ? stackit_ske_cluster.cluster.name : "" + expiration = var.expiration + region = var.region +} + +provider "echo" { + data = ephemeral.stackit_ske_kubeconfig.ephemeral_kubeconfig.kube_config +} + +resource "echo" "example" {} diff --git a/stackit/internal/services/ske/testdata/resource-min.tf b/stackit/internal/services/ske/testdata/resource-min.tf index ab1784e64..32ee914d9 100644 --- a/stackit/internal/services/ske/testdata/resource-min.tf +++ b/stackit/internal/services/ske/testdata/resource-min.tf @@ -54,4 +54,15 @@ data "stackit_ske_cluster" "cluster" { name = stackit_ske_cluster.cluster.name } +ephemeral "stackit_ske_kubeconfig" "ephemeral_kubeconfig" { + project_id = var.project_id + # cluster_name is unknown during the plan phase because stackit_ske_cluster.cluster.id is computed. + # This forces Terraform to defer the Open call until the Apply phase, after the cluster is ready. + cluster_name = stackit_ske_cluster.cluster.id != "" ? stackit_ske_cluster.cluster.name : "" +} + +provider "echo" { + data = ephemeral.stackit_ske_kubeconfig.ephemeral_kubeconfig.kube_config +} +resource "echo" "example" {} diff --git a/stackit/provider.go b/stackit/provider.go index f99c5eb3c..60f67fab5 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -902,5 +902,6 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { func (p *Provider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { return []func() ephemeral.EphemeralResource{ access_token.NewAccessTokenEphemeralResource, + skeKubeconfig.NewKubeconfigEphemeralResource, } } From 646921b2e5645e4ecaf0abb7de33f125ec469c97 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Thu, 9 Jul 2026 13:14:58 +0200 Subject: [PATCH 2/3] review changes Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/ske_kubeconfig.md | 2 +- .../ske/kubeconfig/ephemeral_resource.go | 30 +++------- .../ske/kubeconfig/ephemeral_resource_test.go | 56 +++++++------------ 3 files changed, 27 insertions(+), 61 deletions(-) diff --git a/docs/ephemeral-resources/ske_kubeconfig.md b/docs/ephemeral-resources/ske_kubeconfig.md index 5d73ff3d2..8bf525227 100644 --- a/docs/ephemeral-resources/ske_kubeconfig.md +++ b/docs/ephemeral-resources/ske_kubeconfig.md @@ -43,7 +43,7 @@ provider "kubernetes" { ### Optional -- `expiration` (Number) Expiration time of the kubeconfig in seconds. Must be between `600` (10m) and `14400` (4h). Defaults to `1800` (30m) for optimal security during Terraform operations, which is more restrictive than the API default of `3600` (1h). +- `expiration` (Number) Expiration time of the kubeconfig in seconds. Must be between `600` (10m) and `14400` (4h). API defaults to `3600` (1h). - `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only diff --git a/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go b/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go index 3716517df..cdd118432 100644 --- a/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go +++ b/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go @@ -20,10 +20,6 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) -const ( - defaultKubeconfigExpiration = 1800 -) - // Ensure the implementation satisfies the expected interfaces. var ( _ ephemeral.EphemeralResource = &kubeconfigEphemeralResource{} @@ -94,7 +90,7 @@ func (e *kubeconfigEphemeralResource) Schema(_ context.Context, _ ephemeral.Sche }, "expiration": schema.Int64Attribute{ Description: "Expiration time of the kubeconfig in seconds. Must be between `600` (10m) and `14400` (4h). " + - "Defaults to `1800` (30m) for optimal security during Terraform operations, which is more restrictive than the API default of `3600` (1h).", + "API defaults to `3600` (1h).", Optional: true, Validators: []validator.Int64{ int64validator.AtLeast(600), @@ -135,15 +131,7 @@ func (e *kubeconfigEphemeralResource) Open(ctx context.Context, req ephemeral.Op clusterName := model.ClusterName.ValueString() region := e.providerData.GetRegionWithOverride(model.Region) - // Kubeconfig only needs to be valid for the duration of the Terraform operation. - // Defaulted to 1800s (30m) for better security than the API default (3600s). - expiration := conversion.Int64ValueToPointer(model.Expiration) - if expiration == nil { - expiration = new(int64) - *expiration = defaultKubeconfigExpiration - } - - kubeconfigResp, err := getKubeconfig(ctx, e.client, projectId, region, clusterName, expiration) + kubeconfigResp, err := getKubeconfig(ctx, e.client.DefaultAPI, projectId, region, clusterName, conversion.Int64ValueToPointer(model.Expiration)) ctx = core.LogResponse(ctx) @@ -166,16 +154,12 @@ func (e *kubeconfigEphemeralResource) Open(ctx context.Context, req ephemeral.Op } // getKubeconfig initializes the API call to generate a new kubeconfig -func getKubeconfig(ctx context.Context, client *ske.APIClient, projectId, region, clusterName string, expiration *int64) (*ske.Kubeconfig, error) { - var expirationStringPtr *string +func getKubeconfig(ctx context.Context, client ske.DefaultAPI, projectId, region, clusterName string, expiration *int64) (*ske.Kubeconfig, error) { + payload := ske.CreateKubeconfigPayload{} if expiration != nil { - expirationStringPtr = new(string) - *expirationStringPtr = strconv.FormatInt(*expiration, 10) - } - - payload := ske.CreateKubeconfigPayload{ - ExpirationSeconds: expirationStringPtr, + expirationStr := strconv.FormatInt(*expiration, 10) + payload.ExpirationSeconds = &expirationStr } - return client.DefaultAPI.CreateKubeconfig(ctx, projectId, region, clusterName).CreateKubeconfigPayload(payload).Execute() + return client.CreateKubeconfig(ctx, projectId, region, clusterName).CreateKubeconfigPayload(payload).Execute() } diff --git a/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go b/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go index 1f8974b64..b6ab20642 100644 --- a/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go +++ b/stackit/internal/services/ske/kubeconfig/ephemeral_resource_test.go @@ -2,15 +2,11 @@ package ske import ( "context" - "encoding/json" "fmt" - "net/http" - "net/http/httptest" "testing" "time" "github.com/google/go-cmp/cmp" - "github.com/stackitcloud/stackit-sdk-go/core/config" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) @@ -24,22 +20,21 @@ func TestGetKubeconfig(t *testing.T) { expirationTime := time.Now().Add(time.Hour).Truncate(time.Second) tests := []struct { - description string - expiration *int64 - mockResponse *ske.Kubeconfig - mockStatusCode int - expectError bool + description string + expiration *int64 + mockResponse *ske.Kubeconfig + mockError error + expectError bool }{ { - description: "success", + description: "success without expiration", expiration: nil, mockResponse: &ske.Kubeconfig{ Kubeconfig: &[]string{kubeconfig}[0], ExpirationTimestamp: &expirationTime, AdditionalProperties: make(map[string]any), }, - mockStatusCode: http.StatusOK, - expectError: false, + expectError: false, }, { description: "success with expiration", @@ -49,40 +44,27 @@ func TestGetKubeconfig(t *testing.T) { ExpirationTimestamp: &expirationTime, AdditionalProperties: make(map[string]any), }, - mockStatusCode: http.StatusOK, - expectError: false, + expectError: false, }, { - description: "api error", - mockStatusCode: http.StatusInternalServerError, - expectError: true, + description: "api error", + mockError: fmt.Errorf("api error"), + expectError: true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - expectedPath := fmt.Sprintf("/v2/projects/%s/regions/%s/clusters/%s/kubeconfig", projectId, region, clusterName) - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(tt.mockStatusCode) - if tt.mockResponse != nil { - _ = json.NewEncoder(w).Encode(tt.mockResponse) - } - })) - defer server.Close() - - cfg, err := ske.NewAPIClient( - config.WithEndpoint(server.URL), - config.WithoutAuthentication(), - ) - if err != nil { - t.Fatalf("Failed to create SKE client: %v", err) + mockResp := tt.mockResponse + mockErr := tt.mockError + createKubeconfigFn := func(_ ske.ApiCreateKubeconfigRequest) (*ske.Kubeconfig, error) { + return mockResp, mockErr + } + client := &ske.DefaultAPIServiceMock{ + CreateKubeconfigExecuteMock: &createKubeconfigFn, } - resp, err := getKubeconfig(context.Background(), cfg, projectId, region, clusterName, tt.expiration) + resp, err := getKubeconfig(context.Background(), client, projectId, region, clusterName, tt.expiration) if (err != nil) != tt.expectError { t.Fatalf("getKubeconfig() error = %v, expectError %v", err, tt.expectError) From cb934f47938565365020a0e94defe57f9d62b3fe Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Mon, 20 Jul 2026 12:06:20 +0200 Subject: [PATCH 3/3] add experimental status Signed-off-by: Mauritz Uphoff --- README.md | 6 +++++- docs/ephemeral-resources/ske_kubeconfig.md | 9 ++++++++- docs/index.md | 2 +- .../stackit_ske_kubeconfig/ephemeral-resource.tf | 6 +++++- stackit/internal/features/experiments.go | 3 ++- .../services/ske/kubeconfig/ephemeral_resource.go | 15 +++++++++++++-- stackit/internal/services/ske/ske_acc_test.go | 12 ++++++------ stackit/internal/testutil/testutil.go | 1 + stackit/internal/testutil/testutil_test.go | 4 ++-- 9 files changed, 43 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4f087aad7..a5215d947 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ To enable experiments set the experiments field in the provider definition: ```hcl provider "stackit" { default_region = "eu01" - experiments = ["iam", "routing-tables", "network"] + experiments = ["iam", "routing-tables", "network", "dremio", "ske"] } ``` @@ -221,6 +221,10 @@ Enables the usage and provisioning of STACKIT Dremio resources. The STACKIT Dremio API is currently in alpha state. The fields of the resources are still subject to change. +#### `ske` + +Enables experimental SKE features. These features are subject to change and may be removed or redesigned in future releases. + ## Acceptance Tests > [!WARNING] diff --git a/docs/ephemeral-resources/ske_kubeconfig.md b/docs/ephemeral-resources/ske_kubeconfig.md index 8bf525227..0f3a8a39a 100644 --- a/docs/ephemeral-resources/ske_kubeconfig.md +++ b/docs/ephemeral-resources/ske_kubeconfig.md @@ -4,21 +4,28 @@ page_title: "stackit_ske_kubeconfig Ephemeral Resource - stackit" subcategory: "" description: |- Ephemeral resource that generates a short-lived SKE kubeconfig. A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. + ~> This ephemeral-resource is part of the experimental feature ske and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion. --- # stackit_ske_kubeconfig (Ephemeral Resource) Ephemeral resource that generates a short-lived SKE kubeconfig. A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. +~> This ephemeral-resource is part of the experimental feature ske and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion. + ## Example Usage ```terraform +provider "stackit" { + experiments = ["ske"] +} + resource "stackit_ske_cluster" "example" { # ... cluster configuration ... } # We use the cluster ID ternary to force evaluation during the Apply phase. -# Unlike managed resources, ephemeral resources evaluate during the Plan phase +# Unlike managed resources, ephemeral resources evaluate during the Plan phase # if inputs are known, which would trigger a 404 before the cluster exists. ephemeral "stackit_ske_kubeconfig" "example" { project_id = stackit_ske_cluster.example.project_id diff --git a/docs/index.md b/docs/index.md index c3ceba33e..4c0623665 100644 --- a/docs/index.md +++ b/docs/index.md @@ -182,7 +182,7 @@ See this [example](https://professional-service.git.onstackit.cloud/professional - `dremio_custom_endpoint` (String) Custom endpoint for the Dremio service - `edgecloud_custom_endpoint` (String) Custom endpoint for the Edge Cloud service - `enable_beta_resources` (Boolean) Enable beta resources. Default is false. -- `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: dremio, iam, network, routing-tables, vpc +- `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: dremio, iam, network, routing-tables, vpc, ske - `git_custom_endpoint` (String) Custom endpoint for the Git service - `iaas_custom_endpoint` (String) Custom endpoint for the IaaS service - `intake_custom_endpoint` (String) Custom endpoint for the Intake service diff --git a/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf b/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf index c7b531d53..077c98d4e 100644 --- a/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf +++ b/examples/ephemeral-resources/stackit_ske_kubeconfig/ephemeral-resource.tf @@ -1,9 +1,13 @@ +provider "stackit" { + experiments = ["ske"] +} + resource "stackit_ske_cluster" "example" { # ... cluster configuration ... } # We use the cluster ID ternary to force evaluation during the Apply phase. -# Unlike managed resources, ephemeral resources evaluate during the Plan phase +# Unlike managed resources, ephemeral resources evaluate during the Plan phase # if inputs are known, which would trigger a 404 before the cluster exists. ephemeral "stackit_ske_kubeconfig" "example" { project_id = stackit_ske_cluster.example.project_id diff --git a/stackit/internal/features/experiments.go b/stackit/internal/features/experiments.go index 902e6ecad..3cac7b737 100644 --- a/stackit/internal/features/experiments.go +++ b/stackit/internal/features/experiments.go @@ -18,9 +18,10 @@ const ( IamExperiment = "iam" DremioExperiment = "dremio" VpcExperiment = "vpc" + SkeExperiment = "ske" ) -var AvailableExperiments = []string{DremioExperiment, IamExperiment, NetworkExperiment, RoutingTablesExperiment, VpcExperiment} +var AvailableExperiments = []string{DremioExperiment, IamExperiment, NetworkExperiment, RoutingTablesExperiment, VpcExperiment, SkeExperiment} // Check if an experiment is valid. func ValidExperiment(experiment string, diags *diag.Diagnostics) bool { diff --git a/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go b/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go index cdd118432..1a441a337 100644 --- a/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go +++ b/stackit/internal/services/ske/kubeconfig/ephemeral_resource.go @@ -16,6 +16,7 @@ import ( "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/features" skeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) @@ -50,6 +51,12 @@ func (e *kubeconfigEphemeralResource) Configure(ctx context.Context, req ephemer } e.providerData = ephemeralProviderData.ProviderData + + features.CheckExperimentEnabled(ctx, &e.providerData, features.SkeExperiment, "stackit_ske_kubeconfig", core.EphemeralResource, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + e.client = skeUtils.ConfigureClient(ctx, &e.providerData, &resp.Diagnostics) tflog.Info(ctx, "SKE kubeconfig client configured") @@ -67,8 +74,12 @@ type ephemeralModel struct { // Schema defines the schema for the ephemeral resource. func (e *kubeconfigEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { - description := "Ephemeral resource that generates a short-lived SKE kubeconfig. " + - "A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation." + description := features.AddExperimentDescription( + "Ephemeral resource that generates a short-lived SKE kubeconfig. "+ + "A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation.", + features.SkeExperiment, + core.EphemeralResource, + ) resp.Schema = schema.Schema{ Description: description, diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index c67c768b9..5e8c81a30 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -131,7 +131,7 @@ func TestAccSKEMin(t *testing.T) { Steps: []resource.TestStep{ // 1) Creation { - Config: testutil.NewConfigBuilder().BuildProviderConfig() + "\n" + resourceMin, + Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMin, ConfigVariables: testConfigVarsMin, Check: resource.ComposeAggregateTestCheckFunc( // cluster data @@ -179,7 +179,7 @@ func TestAccSKEMin(t *testing.T) { }, // 2) Data source { - Config: resourceMin, + Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMin, ConfigVariables: testConfigVarsMin, Check: resource.ComposeAggregateTestCheckFunc( @@ -232,7 +232,7 @@ func TestAccSKEMin(t *testing.T) { }, // 4) Update kubernetes version, OS version and maintenance end, downgrade of kubernetes version { - Config: resourceMin, + Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMin, ConfigVariables: configVarsMinUpdated(), ConfigPlanChecks: resource.ConfigPlanChecks{ PreApply: []plancheck.PlanCheck{ @@ -287,7 +287,7 @@ func TestAccSKEMax(t *testing.T) { Steps: []resource.TestStep{ // 1) Creation { - Config: testutil.NewConfigBuilder().BuildProviderConfig() + "\n" + resourceMax, + Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMax, ConfigVariables: testConfigVarsMax, Check: resource.ComposeAggregateTestCheckFunc( // cluster data @@ -372,7 +372,7 @@ func TestAccSKEMax(t *testing.T) { }, // 2) Data source { - Config: resourceMax, + Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMax, ConfigVariables: testConfigVarsMax, Check: resource.ComposeAggregateTestCheckFunc( @@ -460,7 +460,7 @@ func TestAccSKEMax(t *testing.T) { }, // 4) Update kubernetes version, OS version and maintenance end, downgrade of kubernetes version, set access.idp.enabled to false { - Config: resourceMax, + Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMax, ConfigVariables: configVarsMaxUpdated(), ConfigPlanChecks: resource.ConfigPlanChecks{ PreApply: []plancheck.PlanCheck{ diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 3887570cb..07c9e45a4 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -152,6 +152,7 @@ const ( ExperimentIAM Experiment = "iam" ExperimentDremio Experiment = "dremio" ExperimentVPC Experiment = "vpc" + ExperimentSKE Experiment = "ske" ) type customEndpointConfig struct { diff --git a/stackit/internal/testutil/testutil_test.go b/stackit/internal/testutil/testutil_test.go index d4dd144f3..4dde17d45 100644 --- a/stackit/internal/testutil/testutil_test.go +++ b/stackit/internal/testutil/testutil_test.go @@ -95,11 +95,11 @@ func TestConfigBuilderProviderConfig(t *testing.T) { { name: "experiments", builder: NewConfigBuilder(). - Experiments(ExperimentIAM, ExperimentNetwork), + Experiments(ExperimentIAM, ExperimentNetwork, ExperimentSKE), want: `provider "stackit" { default_region = "eu01" enable_beta_resources = false - experiments = ["iam", "network"] + experiments = ["iam", "network", "ske"] }`, }, {