Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/ephemeral-resources/ske_kubeconfig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
# 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.
~> This ephemeral-resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---

# 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 in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.

## 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 generated by tfplugindocs -->
## 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. Defaults to `1800` (30m). Maximum is `14400` (4h).
- `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.
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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")),
),
},
},
Expand Down
194 changes: 194 additions & 0 deletions stackit/internal/services/ske/kubeconfig/ephemeral_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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"
"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"
)

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
}

features.CheckBetaResourcesEnabled(
ctx,
&ephemeralProviderData.ProviderData,
&resp.Diagnostics,
"stackit_ske_kubeconfig", "ephemeral_resource",
)
if resp.Diagnostics.HasError() {
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 := features.AddBetaDescription(
"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.",
core.EphemeralResource,
)

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. Defaults to `1800` (30m). Maximum is `14400` (4h).",
Optional: true,
Validators: []validator.Int64{
int64validator.AtLeast(60),
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()
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading
Loading