Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
```

Expand All @@ -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]
Expand Down
59 changes: 59 additions & 0 deletions docs/ephemeral-resources/ske_kubeconfig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
# 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 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
# 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. 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

- `expires_at` (String) Timestamp when the kubeconfig expires.
- `kube_config` (String, Sensitive) Raw short-lived admin kubeconfig.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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
# 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 : ""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here

}

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)
}
3 changes: 2 additions & 1 deletion stackit/internal/features/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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
176 changes: 176 additions & 0 deletions stackit/internal/services/ske/kubeconfig/ephemeral_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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"
)

// 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

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")
}

// 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.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,
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). " +
"API defaults to `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)

kubeconfigResp, err := getKubeconfig(ctx, e.client.DefaultAPI, projectId, region, clusterName, conversion.Int64ValueToPointer(model.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.DefaultAPI, projectId, region, clusterName string, expiration *int64) (*ske.Kubeconfig, error) {
payload := ske.CreateKubeconfigPayload{}
if expiration != nil {
expirationStr := strconv.FormatInt(*expiration, 10)
payload.ExpirationSeconds = &expirationStr
}

return client.CreateKubeconfig(ctx, projectId, region, clusterName).CreateKubeconfigPayload(payload).Execute()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package ske

import (
"context"
"fmt"
"testing"
"time"

"github.com/google/go-cmp/cmp"
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
mockError error
expectError bool
}{
{
description: "success without expiration",
expiration: nil,
mockResponse: &ske.Kubeconfig{
Kubeconfig: &[]string{kubeconfig}[0],
ExpirationTimestamp: &expirationTime,
AdditionalProperties: make(map[string]any),
},
expectError: false,
},
{
description: "success with expiration",
expiration: &[]int64{3600}[0],
mockResponse: &ske.Kubeconfig{
Kubeconfig: &[]string{kubeconfig}[0],
ExpirationTimestamp: &expirationTime,
AdditionalProperties: make(map[string]any),
},
expectError: false,
},
{
description: "api error",
mockError: fmt.Errorf("api error"),
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
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(), client, 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