Skip to content

Commit e7a7211

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

10 files changed

Lines changed: 401 additions & 13 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
~> 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.
8+
---
9+
10+
# stackit_ske_kubeconfig (Ephemeral Resource)
11+
12+
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.
13+
14+
~> 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.
15+
16+
## Example Usage
17+
18+
```terraform
19+
ephemeral "stackit_ske_kubeconfig" "example" {
20+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
21+
cluster_name = "example-cluster"
22+
}
23+
```
24+
25+
<!-- schema generated by tfplugindocs -->
26+
## Schema
27+
28+
### Required
29+
30+
- `cluster_name` (String) Name of the SKE cluster.
31+
- `project_id` (String) STACKIT project ID to which the cluster is associated.
32+
33+
### Optional
34+
35+
- `expiration` (Number) Expiration time of the kubeconfig, in seconds. Defaults to `1800` (30m). Maximum is `14400` (4h).
36+
- `region` (String) The resource region. If not defined, the provider region is used.
37+
38+
### Read-Only
39+
40+
- `expires_at` (String) Timestamp when the kubeconfig expires.
41+
- `kube_config` (String, Sensitive) Raw short-lived admin kubeconfig.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ephemeral "stackit_ske_kubeconfig" "example" {
2+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
3+
cluster_name = "example-cluster"
4+
}

stackit/internal/services/access_token/access_token_acc_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,16 @@ 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(
37-
"echo.example",
38-
tfjsonpath.New("data").AtMapKey("access_token"),
38+
"output.access_token",
39+
tfjsonpath.New("value"),
3940
knownvalue.NotNull(),
4041
),
41-
// JWT access tokens start with "ey" because the first part is base64-encoded JSON that begins with "{".
42+
// Check that the output value matches the JWT 'ey' prefix
4243
statecheck.ExpectKnownValue(
43-
"echo.example",
44-
tfjsonpath.New("data").AtMapKey("access_token"),
44+
"output.access_token",
45+
tfjsonpath.New("value"),
4546
knownvalue.StringRegexp(regexp.MustCompile(`^ey`)),
4647
),
4748
},

stackit/internal/services/access_token/testdata/ephemeral_resource.tf

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ provider "stackit" {
77

88
ephemeral "stackit_access_token" "example" {}
99

10-
provider "echo" {
11-
data = ephemeral.stackit_access_token.example
12-
}
13-
14-
resource "echo" "example" {
15-
}
10+
output "access_token" {
11+
value = ephemeral.stackit_access_token.example.access_token
12+
sensitive = true
13+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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+
ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api"
15+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
16+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
17+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
18+
skeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/utils"
19+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
20+
)
21+
22+
const (
23+
defaultKubeconfigExpiration = 1800
24+
)
25+
26+
// Ensure the implementation satisfies the expected interfaces.
27+
var (
28+
_ ephemeral.EphemeralResource = &kubeconfigEphemeralResource{}
29+
_ ephemeral.EphemeralResourceWithConfigure = &kubeconfigEphemeralResource{}
30+
)
31+
32+
// NewKubeconfigEphemeralResource is a helper function to simplify the provider implementation.
33+
func NewKubeconfigEphemeralResource() ephemeral.EphemeralResource {
34+
return &kubeconfigEphemeralResource{}
35+
}
36+
37+
// kubeconfigEphemeralResource is the ephemeral resource implementation.
38+
type kubeconfigEphemeralResource struct {
39+
client *ske.APIClient
40+
providerData core.ProviderData
41+
}
42+
43+
// Metadata returns the resource type name.
44+
func (e *kubeconfigEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
45+
resp.TypeName = req.ProviderTypeName + "_ske_kubeconfig"
46+
}
47+
48+
// Configure adds the provider configured client to the resource.
49+
func (e *kubeconfigEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
50+
ephemeralProviderData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics)
51+
if !ok {
52+
return
53+
}
54+
55+
features.CheckBetaResourcesEnabled(
56+
ctx,
57+
&ephemeralProviderData.ProviderData,
58+
&resp.Diagnostics,
59+
"stackit_ske_kubeconfig", "ephemeral_resource",
60+
)
61+
if resp.Diagnostics.HasError() {
62+
return
63+
}
64+
65+
e.providerData = ephemeralProviderData.ProviderData
66+
e.client = skeUtils.ConfigureClient(ctx, &e.providerData, &resp.Diagnostics)
67+
}
68+
69+
// ephemeralModel is the model for the ephemeral resource.
70+
type ephemeralModel struct {
71+
ClusterName types.String `tfsdk:"cluster_name"`
72+
ProjectId types.String `tfsdk:"project_id"`
73+
Expiration types.Int64 `tfsdk:"expiration"`
74+
Region types.String `tfsdk:"region"`
75+
Kubeconfig types.String `tfsdk:"kube_config"`
76+
ExpiresAt types.String `tfsdk:"expires_at"`
77+
}
78+
79+
// Schema defines the schema for the ephemeral resource.
80+
func (e *kubeconfigEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
81+
description := features.AddBetaDescription(
82+
"Ephemeral resource that generates a short-lived SKE kubeconfig. "+
83+
"A new kubeconfig is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation.",
84+
core.EphemeralResource,
85+
)
86+
87+
resp.Schema = schema.Schema{
88+
Description: description,
89+
Attributes: map[string]schema.Attribute{
90+
"cluster_name": schema.StringAttribute{
91+
Description: "Name of the SKE cluster.",
92+
Required: true,
93+
Validators: []validator.String{
94+
validate.NoSeparator(),
95+
},
96+
},
97+
"project_id": schema.StringAttribute{
98+
Description: "STACKIT project ID to which the cluster is associated.",
99+
Required: true,
100+
Validators: []validator.String{
101+
validate.UUID(),
102+
validate.NoSeparator(),
103+
},
104+
},
105+
"expiration": schema.Int64Attribute{
106+
Description: "Expiration time of the kubeconfig, in seconds. Defaults to `1800` (30m). Maximum is `14400` (4h).",
107+
Optional: true,
108+
Validators: []validator.Int64{
109+
int64validator.AtLeast(60),
110+
int64validator.AtMost(14400),
111+
},
112+
},
113+
"region": schema.StringAttribute{
114+
Description: "The resource region. If not defined, the provider region is used.",
115+
Optional: true,
116+
},
117+
"kube_config": schema.StringAttribute{
118+
Description: "Raw short-lived admin kubeconfig.",
119+
Computed: true,
120+
Sensitive: true,
121+
},
122+
"expires_at": schema.StringAttribute{
123+
Description: "Timestamp when the kubeconfig expires.",
124+
Computed: true,
125+
},
126+
},
127+
}
128+
}
129+
130+
// Open creates the kubeconfig and sets the result.
131+
func (e *kubeconfigEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
132+
var model ephemeralModel
133+
134+
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
135+
if resp.Diagnostics.HasError() {
136+
return
137+
}
138+
139+
ctx = core.InitProviderContext(ctx)
140+
141+
projectId := model.ProjectId.ValueString()
142+
clusterName := model.ClusterName.ValueString()
143+
region := e.providerData.GetRegionWithOverride(model.Region)
144+
145+
// Kubeconfig only needs to be valid for the duration of the Terraform operation.
146+
// Defaulted to 1800s (30m) for better security than the API default (3600s).
147+
expiration := conversion.Int64ValueToPointer(model.Expiration)
148+
if expiration == nil {
149+
expiration = new(int64)
150+
*expiration = defaultKubeconfigExpiration
151+
}
152+
153+
kubeconfigResp, err := getKubeconfig(ctx, e.client, projectId, region, clusterName, expiration)
154+
155+
ctx = core.LogResponse(ctx)
156+
157+
if err != nil {
158+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Calling SKE API: %v", err))
159+
return
160+
}
161+
162+
if kubeconfigResp == nil || kubeconfigResp.Kubeconfig == nil {
163+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", "API returned an empty response")
164+
return
165+
}
166+
167+
model.Kubeconfig = types.StringPointerValue(kubeconfigResp.Kubeconfig)
168+
model.ExpiresAt = types.StringValue(kubeconfigResp.ExpirationTimestamp.Format(time.RFC3339))
169+
model.Region = types.StringValue(region)
170+
171+
resp.Diagnostics.Append(resp.Result.Set(ctx, model)...)
172+
}
173+
174+
// getKubeconfig initializes the API call to generate a new kubeconfig
175+
func getKubeconfig(ctx context.Context, client *ske.APIClient, projectId, region, clusterName string, expiration *int64) (*ske.Kubeconfig, error) {
176+
var expirationStringPtr *string
177+
if expiration != nil {
178+
expirationStringPtr = new(string)
179+
*expirationStringPtr = strconv.FormatInt(*expiration, 10)
180+
}
181+
182+
payload := ske.CreateKubeconfigPayload{
183+
ExpirationSeconds: expirationStringPtr,
184+
}
185+
186+
return client.DefaultAPI.CreateKubeconfig(ctx, projectId, region, clusterName).CreateKubeconfigPayload(payload).Execute()
187+
}
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)