Skip to content

Commit ac521c3

Browse files
committed
feat(ske): add ephemeral ske kubeconfig
Signed-off-by: Mauritz Uphoff <mauritz.uphoff@stackit.cloud>
1 parent 131e627 commit ac521c3

9 files changed

Lines changed: 404 additions & 6 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "stackit_ske_kubeconfig Ephemeral Resource - stackit"
4+
subcategory: ""
5+
description: |-
6+
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.
7+
---
8+
9+
# stackit_ske_kubeconfig (Ephemeral Resource)
10+
11+
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.
12+
13+
## Example Usage
14+
15+
```terraform
16+
resource "stackit_ske_cluster" "example" {
17+
# ... cluster configuration ...
18+
}
19+
20+
# We use the cluster ID ternary to force evaluation during the Apply phase.
21+
# Unlike managed resources, ephemeral resources evaluate during the Plan phase
22+
# if inputs are known, which would trigger a 404 before the cluster exists.
23+
ephemeral "stackit_ske_kubeconfig" "example" {
24+
project_id = stackit_ske_cluster.example.project_id
25+
cluster_name = stackit_ske_cluster.example.id != "" ? stackit_ske_cluster.example.name : ""
26+
}
27+
28+
provider "kubernetes" {
29+
host = yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.server
30+
client_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-certificate-data)
31+
client_key = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-key-data)
32+
cluster_ca_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.certificate-authority-data)
33+
}
34+
```
35+
36+
<!-- schema generated by tfplugindocs -->
37+
## Schema
38+
39+
### Required
40+
41+
- `cluster_name` (String) Name of the SKE cluster.
42+
- `project_id` (String) STACKIT project ID to which the cluster is associated.
43+
44+
### Optional
45+
46+
- `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).
47+
- `region` (String) The resource region. If not defined, the provider region is used.
48+
49+
### Read-Only
50+
51+
- `expires_at` (String) Timestamp when the kubeconfig expires.
52+
- `kube_config` (String, Sensitive) Raw short-lived admin kubeconfig.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
resource "stackit_ske_cluster" "example" {
2+
# ... cluster configuration ...
3+
}
4+
5+
# We use the cluster ID ternary to force evaluation during the Apply phase.
6+
# Unlike managed resources, ephemeral resources evaluate during the Plan phase
7+
# if inputs are known, which would trigger a 404 before the cluster exists.
8+
ephemeral "stackit_ske_kubeconfig" "example" {
9+
project_id = stackit_ske_cluster.example.project_id
10+
cluster_name = stackit_ske_cluster.example.id != "" ? stackit_ske_cluster.example.name : ""
11+
}
12+
13+
provider "kubernetes" {
14+
host = yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.server
15+
client_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-certificate-data)
16+
client_key = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).users.0.user.client-key-data)
17+
cluster_ca_certificate = base64decode(yamldecode(ephemeral.stackit_ske_kubeconfig.example.kube_config).clusters.0.cluster.certificate-authority-data)
18+
}

stackit/internal/services/access_token/access_token_acc_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func TestAccEphemeralAccessToken(t *testing.T) {
3333
Config: ephemeralResourceConfig,
3434
ConfigVariables: testConfigVars,
3535
ConfigStateChecks: []statecheck.StateCheck{
36+
// Check that the output is not null
3637
statecheck.ExpectKnownValue(
3738
"echo.example",
3839
tfjsonpath.New("data").AtMapKey("access_token"),
@@ -42,7 +43,7 @@ func TestAccEphemeralAccessToken(t *testing.T) {
4243
statecheck.ExpectKnownValue(
4344
"echo.example",
4445
tfjsonpath.New("data").AtMapKey("access_token"),
45-
knownvalue.StringRegexp(regexp.MustCompile(`^ey`)),
46+
knownvalue.StringRegexp(regexp.MustCompile("^ey")),
4647
),
4748
},
4849
},
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package ske
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"time"
8+
9+
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
10+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
11+
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
12+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
13+
"github.com/hashicorp/terraform-plugin-framework/types"
14+
"github.com/hashicorp/terraform-plugin-log/tflog"
15+
ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api"
16+
17+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
18+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
19+
skeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/utils"
20+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
21+
)
22+
23+
const (
24+
defaultKubeconfigExpiration = 1800
25+
)
26+
27+
// Ensure the implementation satisfies the expected interfaces.
28+
var (
29+
_ ephemeral.EphemeralResource = &kubeconfigEphemeralResource{}
30+
_ ephemeral.EphemeralResourceWithConfigure = &kubeconfigEphemeralResource{}
31+
)
32+
33+
// NewKubeconfigEphemeralResource is a helper function to simplify the provider implementation.
34+
func NewKubeconfigEphemeralResource() ephemeral.EphemeralResource {
35+
return &kubeconfigEphemeralResource{}
36+
}
37+
38+
// kubeconfigEphemeralResource is the ephemeral resource implementation.
39+
type kubeconfigEphemeralResource struct {
40+
client *ske.APIClient
41+
providerData core.ProviderData
42+
}
43+
44+
// Metadata returns the resource type name.
45+
func (e *kubeconfigEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
46+
resp.TypeName = req.ProviderTypeName + "_ske_kubeconfig"
47+
}
48+
49+
// Configure adds the provider configured client to the resource.
50+
func (e *kubeconfigEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
51+
ephemeralProviderData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics)
52+
if !ok {
53+
return
54+
}
55+
56+
e.providerData = ephemeralProviderData.ProviderData
57+
e.client = skeUtils.ConfigureClient(ctx, &e.providerData, &resp.Diagnostics)
58+
59+
tflog.Info(ctx, "SKE kubeconfig client configured")
60+
}
61+
62+
// ephemeralModel is the model for the ephemeral resource.
63+
type ephemeralModel struct {
64+
ClusterName types.String `tfsdk:"cluster_name"`
65+
ProjectId types.String `tfsdk:"project_id"`
66+
Expiration types.Int64 `tfsdk:"expiration"`
67+
Region types.String `tfsdk:"region"`
68+
Kubeconfig types.String `tfsdk:"kube_config"`
69+
ExpiresAt types.String `tfsdk:"expires_at"`
70+
}
71+
72+
// Schema defines the schema for the ephemeral resource.
73+
func (e *kubeconfigEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
74+
description := "Ephemeral resource that generates a short-lived SKE kubeconfig. " +
75+
"A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation."
76+
77+
resp.Schema = schema.Schema{
78+
Description: description,
79+
Attributes: map[string]schema.Attribute{
80+
"cluster_name": schema.StringAttribute{
81+
Description: "Name of the SKE cluster.",
82+
Required: true,
83+
Validators: []validator.String{
84+
validate.NoSeparator(),
85+
},
86+
},
87+
"project_id": schema.StringAttribute{
88+
Description: "STACKIT project ID to which the cluster is associated.",
89+
Required: true,
90+
Validators: []validator.String{
91+
validate.UUID(),
92+
validate.NoSeparator(),
93+
},
94+
},
95+
"expiration": schema.Int64Attribute{
96+
Description: "Expiration time of the kubeconfig in seconds. Must be between `600` (10m) and `14400` (4h). " +
97+
"Defaults to `1800` (30m) for optimal security during Terraform operations, which is more restrictive than the API default of `3600` (1h).",
98+
Optional: true,
99+
Validators: []validator.Int64{
100+
int64validator.AtLeast(600),
101+
int64validator.AtMost(14400),
102+
},
103+
},
104+
"region": schema.StringAttribute{
105+
Optional: true,
106+
// must be computed to allow for storing the override value from the provider
107+
Computed: true,
108+
Description: "The resource region. If not defined, the provider region is used.",
109+
},
110+
"kube_config": schema.StringAttribute{
111+
Description: "Raw short-lived admin kubeconfig.",
112+
Computed: true,
113+
Sensitive: true,
114+
},
115+
"expires_at": schema.StringAttribute{
116+
Description: "Timestamp when the kubeconfig expires.",
117+
Computed: true,
118+
},
119+
},
120+
}
121+
}
122+
123+
// Open creates the kubeconfig and sets the result.
124+
func (e *kubeconfigEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
125+
var model ephemeralModel
126+
127+
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
128+
if resp.Diagnostics.HasError() {
129+
return
130+
}
131+
132+
ctx = core.InitProviderContext(ctx)
133+
134+
projectId := model.ProjectId.ValueString()
135+
clusterName := model.ClusterName.ValueString()
136+
region := e.providerData.GetRegionWithOverride(model.Region)
137+
138+
// Kubeconfig only needs to be valid for the duration of the Terraform operation.
139+
// Defaulted to 1800s (30m) for better security than the API default (3600s).
140+
expiration := conversion.Int64ValueToPointer(model.Expiration)
141+
if expiration == nil {
142+
expiration = new(int64)
143+
*expiration = defaultKubeconfigExpiration
144+
}
145+
146+
kubeconfigResp, err := getKubeconfig(ctx, e.client, projectId, region, clusterName, expiration)
147+
148+
ctx = core.LogResponse(ctx)
149+
150+
if err != nil {
151+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Calling SKE API: %v", err))
152+
return
153+
}
154+
155+
if kubeconfigResp == nil || kubeconfigResp.Kubeconfig == nil {
156+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", "API returned an empty response")
157+
return
158+
}
159+
160+
model.Kubeconfig = types.StringPointerValue(kubeconfigResp.Kubeconfig)
161+
model.ExpiresAt = types.StringValue(kubeconfigResp.ExpirationTimestamp.Format(time.RFC3339))
162+
model.Region = types.StringValue(region)
163+
164+
resp.Diagnostics.Append(resp.Result.Set(ctx, model)...)
165+
tflog.Info(ctx, "SKE kubeconfig opened")
166+
}
167+
168+
// getKubeconfig initializes the API call to generate a new kubeconfig
169+
func getKubeconfig(ctx context.Context, client *ske.APIClient, projectId, region, clusterName string, expiration *int64) (*ske.Kubeconfig, error) {
170+
var expirationStringPtr *string
171+
if expiration != nil {
172+
expirationStringPtr = new(string)
173+
*expirationStringPtr = strconv.FormatInt(*expiration, 10)
174+
}
175+
176+
payload := ske.CreateKubeconfigPayload{
177+
ExpirationSeconds: expirationStringPtr,
178+
}
179+
180+
return client.DefaultAPI.CreateKubeconfig(ctx, projectId, region, clusterName).CreateKubeconfigPayload(payload).Execute()
181+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package ske
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/stackitcloud/stackit-sdk-go/core/config"
14+
ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api"
15+
)
16+
17+
func TestGetKubeconfig(t *testing.T) {
18+
const (
19+
projectId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
20+
clusterName = "cluster"
21+
region = "eu01"
22+
kubeconfig = "mock-kubeconfig"
23+
)
24+
expirationTime := time.Now().Add(time.Hour).Truncate(time.Second)
25+
26+
tests := []struct {
27+
description string
28+
expiration *int64
29+
mockResponse *ske.Kubeconfig
30+
mockStatusCode int
31+
expectError bool
32+
}{
33+
{
34+
description: "success",
35+
expiration: nil,
36+
mockResponse: &ske.Kubeconfig{
37+
Kubeconfig: &[]string{kubeconfig}[0],
38+
ExpirationTimestamp: &expirationTime,
39+
AdditionalProperties: make(map[string]any),
40+
},
41+
mockStatusCode: http.StatusOK,
42+
expectError: false,
43+
},
44+
{
45+
description: "success with expiration",
46+
expiration: &[]int64{3600}[0],
47+
mockResponse: &ske.Kubeconfig{
48+
Kubeconfig: &[]string{kubeconfig}[0],
49+
ExpirationTimestamp: &expirationTime,
50+
AdditionalProperties: make(map[string]any),
51+
},
52+
mockStatusCode: http.StatusOK,
53+
expectError: false,
54+
},
55+
{
56+
description: "api error",
57+
mockStatusCode: http.StatusInternalServerError,
58+
expectError: true,
59+
},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.description, func(t *testing.T) {
64+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+
expectedPath := fmt.Sprintf("/v2/projects/%s/regions/%s/clusters/%s/kubeconfig", projectId, region, clusterName)
66+
if r.URL.Path != expectedPath {
67+
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
68+
}
69+
w.Header().Set("Content-Type", "application/json")
70+
w.WriteHeader(tt.mockStatusCode)
71+
if tt.mockResponse != nil {
72+
_ = json.NewEncoder(w).Encode(tt.mockResponse)
73+
}
74+
}))
75+
defer server.Close()
76+
77+
cfg, err := ske.NewAPIClient(
78+
config.WithEndpoint(server.URL),
79+
config.WithoutAuthentication(),
80+
)
81+
if err != nil {
82+
t.Fatalf("Failed to create SKE client: %v", err)
83+
}
84+
85+
resp, err := getKubeconfig(context.Background(), cfg, projectId, region, clusterName, tt.expiration)
86+
87+
if (err != nil) != tt.expectError {
88+
t.Fatalf("getKubeconfig() error = %v, expectError %v", err, tt.expectError)
89+
}
90+
91+
if !tt.expectError {
92+
if diff := cmp.Diff(resp, tt.mockResponse); diff != "" {
93+
t.Errorf("Response mismatch (-want +got):\n%s", diff)
94+
}
95+
}
96+
})
97+
}
98+
}

0 commit comments

Comments
 (0)