From 4e4410b23b6e81c177fbb78e2901f06fff4aa06b Mon Sep 17 00:00:00 2001 From: Tobias Pfaffelmoser Date: Tue, 7 Jul 2026 11:24:10 +0200 Subject: [PATCH 1/4] feat(ske): support audit log configuration --- docs/data-sources/ske_cluster.md | 10 ++ docs/resources/ske_cluster.md | 10 ++ go.mod | 2 +- go.sum | 4 +- .../services/ske/cluster/datasource.go | 10 ++ .../internal/services/ske/cluster/resource.go | 84 ++++++++++++ .../services/ske/cluster/resource_test.go | 122 ++++++++++++++++++ 7 files changed, 239 insertions(+), 3 deletions(-) diff --git a/docs/data-sources/ske_cluster.md b/docs/data-sources/ske_cluster.md index c0ead7d66..41a23f138 100644 --- a/docs/data-sources/ske_cluster.md +++ b/docs/data-sources/ske_cluster.md @@ -34,6 +34,7 @@ data "stackit_ske_cluster" "example" { ### Read-Only - `access` (Attributes) Configure access to the cluster (see [below for nested schema](#nestedatt--access)) +- `audit` (Attributes) Cluster audit log forwarding configuration. This feature is in private preview. Enabling audit logging is only possible for enabled accounts. (see [below for nested schema](#nestedatt--audit)) - `egress_address_ranges` (List of String) The outgoing network ranges (in CIDR notation) of traffic originating from workload on the cluster. - `extensions` (Attributes) A single extensions block as defined below (see [below for nested schema](#nestedatt--extensions)) - `hibernations` (Attributes List) One or more hibernation block as defined below. (see [below for nested schema](#nestedatt--hibernations)) @@ -63,6 +64,15 @@ Read-Only: + +### Nested Schema for `audit` + +Read-Only: + +- `enabled` (Boolean) Enable cluster audit log forwarding to a Telemetry Router. + + + ### Nested Schema for `extensions` diff --git a/docs/resources/ske_cluster.md b/docs/resources/ske_cluster.md index 876ed8007..31257d0f8 100644 --- a/docs/resources/ske_cluster.md +++ b/docs/resources/ske_cluster.md @@ -60,6 +60,7 @@ To keep your Terraform plans clean and readable, always append new node pools to ### Optional - `access` (Attributes) Configure access to the cluster (see [below for nested schema](#nestedatt--access)) +- `audit` (Attributes) Cluster audit log forwarding configuration. This feature is in private preview. Enabling audit logging is only possible for enabled accounts. (see [below for nested schema](#nestedatt--audit)) - `extensions` (Attributes) A single extensions block as defined below. (see [below for nested schema](#nestedatt--extensions)) - `hibernations` (Attributes List) One or more hibernation block as defined below. (see [below for nested schema](#nestedatt--hibernations)) - `kubernetes_version_min` (String) The minimum Kubernetes version. This field will be used to set the minimum kubernetes version on creation/update of the cluster. If unset, the latest supported Kubernetes version will be used. SKE automatically updates the cluster Kubernetes version if you have set `maintenance.enable_kubernetes_version_updates` to true or if there is a mandatory update, as described in [General information for Kubernetes & OS updates](https://docs.stackit.cloud/products/runtime/kubernetes-engine/basics/version-updates/). To get the current kubernetes version being used for your cluster, use the read-only `kubernetes_version_used` field. @@ -135,6 +136,15 @@ Optional: + +### Nested Schema for `audit` + +Required: + +- `enabled` (Boolean) Enable cluster audit log forwarding to a Telemetry Router. + + + ### Nested Schema for `extensions` diff --git a/go.mod b/go.mod index 942312854..ecaae741d 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.20.0 github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0 github.com/stackitcloud/stackit-sdk-go/services/sfs v0.11.0 - github.com/stackitcloud/stackit-sdk-go/services/ske v1.18.0 + github.com/stackitcloud/stackit-sdk-go/services/ske v1.19.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.0 github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.2.0 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.3.0 diff --git a/go.sum b/go.sum index bca7caf55..7e6a9a93a 100644 --- a/go.sum +++ b/go.sum @@ -732,8 +732,8 @@ github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0 h1:TNZH github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0/go.mod h1:fXq3TmVLb4JMSve989NFFViMFoYa83s7M3hJWgN6mdQ= github.com/stackitcloud/stackit-sdk-go/services/sfs v0.11.0 h1:YhQ2GF+AyHVUps1iEjLj7z6OlzBzMGyD5nwHaZ0pXus= github.com/stackitcloud/stackit-sdk-go/services/sfs v0.11.0/go.mod h1:jMlBoXqrPNX5nXbo6oT7exalqilw1jiLPoIp4Cn0CdI= -github.com/stackitcloud/stackit-sdk-go/services/ske v1.18.0 h1:6Umom7V12bVa7D3dnMJAKj/GWg7wqR9FpqmZ8TjsO1Q= -github.com/stackitcloud/stackit-sdk-go/services/ske v1.18.0/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.19.0 h1:/SNc1zedje1HNhMCWf+IRx2eKpFXeGdmCRCIZ4PCrhs= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.19.0/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.0 h1:A6zN5JUz5DOTigTxAyAC+4p5VpJzaYDftx2yIH0mgn8= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.0/go.mod h1:AiUoMAqQcOlMgDtkVJlqI7P/VGD5xjN3dYjERGnwN/M= github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.2.0 h1:U1mQoCk0TXc8NsSc/Sl9PKMdEyJpWNU2zLnsqmx6wEc= diff --git a/stackit/internal/services/ske/cluster/datasource.go b/stackit/internal/services/ske/cluster/datasource.go index bd3013815..6d00cd996 100644 --- a/stackit/internal/services/ske/cluster/datasource.go +++ b/stackit/internal/services/ske/cluster/datasource.go @@ -329,6 +329,16 @@ func (r *clusterDataSource) Schema(_ context.Context, _ datasource.SchemaRequest }, }, }, + "audit": schema.SingleNestedAttribute{ + Description: descriptions["audit"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: descriptions["audit_enabled"], + Computed: true, + }, + }, + }, "region": schema.StringAttribute{ // the region cannot be found, so it has to be passed Optional: true, diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index d6ffb4b4c..cb3fec6a8 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -92,6 +92,7 @@ type Model struct { Network types.Object `tfsdk:"network"` Hibernations types.List `tfsdk:"hibernations"` Extensions types.Object `tfsdk:"extensions"` + Audit types.Object `tfsdk:"audit"` EgressAddressRanges types.List `tfsdk:"egress_address_ranges"` PodAddressRanges types.List `tfsdk:"pod_address_ranges"` ServiceAccountIssuer types.String `tfsdk:"service_account_issuer"` @@ -270,6 +271,16 @@ var dnsTypes = map[string]attr.Type{ "zones": basetypes.ListType{ElemType: types.StringType}, } +// Struct corresponding to Model.Audit +type audit struct { + Enabled types.Bool `tfsdk:"enabled"` +} + +// Types corresponding to audit +var auditTypes = map[string]attr.Type{ + "enabled": basetypes.BoolType{}, +} + // NewClusterResource is a helper function to simplify the provider implementation. func NewClusterResource() resource.Resource { return &clusterResource{} @@ -412,6 +423,8 @@ var descriptions = map[string]string{ "access_idp": "Configure IDP", "access_idp_enabled": "Enable IDP integration for the cluster.", "access_idp_type": "The IDP type. Possible values: 'stackit'.", + "audit": "Cluster audit log forwarding configuration. This feature is in private preview. Enabling audit logging is only possible for enabled accounts.", + "audit_enabled": "Enable cluster audit log forwarding to a Telemetry Router.", } // Schema defines the schema for the resource. @@ -817,6 +830,19 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, }, + "audit": schema.SingleNestedAttribute{ + Description: descriptions["audit"], + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: descriptions["audit_enabled"], + Required: true, + }, + }, + }, "region": schema.StringAttribute{ Optional: true, // must be computed to allow for storing the override value from the provider @@ -1055,6 +1081,11 @@ func (r *clusterResource) createOrUpdateCluster(ctx context.Context, diags *diag core.LogAndAddError(ctx, diags, "Error creating/updating cluster", fmt.Sprintf("Creating extension API payload: %v", err)) return } + audit, err := toAuditPayload(ctx, model) + if err != nil { + core.LogAndAddError(ctx, diags, "Error creating/updating cluster", fmt.Sprintf("Creating audit API payload: %v", err)) + return + } access, err := toAccessPayload(ctx, model) if err != nil { core.LogAndAddError(ctx, diags, "Error creating/updating cluster", fmt.Sprintf("Creating access API payload: %v", err)) @@ -1062,6 +1093,7 @@ func (r *clusterResource) createOrUpdateCluster(ctx context.Context, diags *diag } payload := ske.CreateOrUpdateClusterPayload{ + Audit: audit, Extensions: extensions, Hibernation: hibernations, Kubernetes: *kubernetes, @@ -1391,6 +1423,22 @@ func toHibernationsPayload(ctx context.Context, m *Model) (*ske.Hibernation, err }, nil } +func toAuditPayload(ctx context.Context, m *Model) (*ske.Audit, error) { + if utils.IsUndefined(m.Audit) { + return nil, nil + } + + auditModel := audit{} + diags := m.Audit.As(ctx, &auditModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("converting audit object: %v", diags.Errors()) + } + + return &ske.Audit{ + Enabled: auditModel.Enabled.ValueBool(), + }, nil +} + func toExtensionsPayload(ctx context.Context, m *Model) (*ske.Extension, error) { if m.Extensions.IsNull() || m.Extensions.IsUnknown() { return nil, nil @@ -1635,6 +1683,10 @@ func mapFields(ctx context.Context, cl *ske.Cluster, m *Model, region string) er if err != nil { return fmt.Errorf("map hibernations: %w", err) } + err = mapAudit(ctx, cl, m) + if err != nil { + return fmt.Errorf("map audit: %w", err) + } err = mapExtensions(ctx, cl, m) if err != nil { return fmt.Errorf("map extensions: %w", err) @@ -1943,6 +1995,38 @@ func getMaintenanceTimes(ctx context.Context, cl *ske.Cluster, m *Model) (startT return startTime, endTime, nil } +func mapAudit(ctx context.Context, cl *ske.Cluster, m *Model) error { + if cl.Audit == nil { + if m.Audit.IsNull() || m.Audit.IsUnknown() || m.Audit.Attributes() == nil { + m.Audit = types.ObjectNull(auditTypes) + return nil + } + + auditModel := audit{} + diags := m.Audit.As(ctx, &auditModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return fmt.Errorf("converting audit object: %v", diags.Errors()) + } + + if !auditModel.Enabled.IsUnknown() && !auditModel.Enabled.ValueBool() { + return nil + } + + m.Audit = types.ObjectNull(auditTypes) + return nil + } + + auditValues := map[string]attr.Value{ + "enabled": types.BoolValue(cl.Audit.Enabled), + } + auditObject, diags := types.ObjectValue(auditTypes, auditValues) + if diags.HasError() { + return fmt.Errorf("creating audit object: %w", core.DiagsToError(diags)) + } + m.Audit = auditObject + return nil +} + func checkDisabledExtensions(ctx context.Context, ex *extensions) (aclDisabled, observabilityDisabled, dnsDisabled bool, err error) { var diags diag.Diagnostics acl := acl{} diff --git a/stackit/internal/services/ske/cluster/resource_test.go b/stackit/internal/services/ske/cluster/resource_test.go index f277522ac..3aecb705b 100644 --- a/stackit/internal/services/ske/cluster/resource_test.go +++ b/stackit/internal/services/ske/cluster/resource_test.go @@ -796,6 +796,9 @@ func TestMapFields(t *testing.T) { t.Fatalf("Should not have failed: %v", err) } if tt.isValid { + if tt.expected.Audit.Attributes() == nil { + tt.expected.Audit = types.ObjectNull(auditTypes) + } diff := cmp.Diff(state, &tt.expected) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -2401,6 +2404,64 @@ func TestToNetworkPayload(t *testing.T) { } } +func TestToAuditPayload(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input types.Object + want *ske.Audit + wantErr bool + }{ + { + name: "null audit", + input: types.ObjectNull(auditTypes), + want: nil, + }, + { + name: "unknown audit", + input: types.ObjectUnknown(auditTypes), + want: nil, + }, + { + name: "audit enabled", + input: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(true), + }), + want: &ske.Audit{ + Enabled: true, + }, + }, + { + name: "audit disabled", + input: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(false), + }), + want: &ske.Audit{ + Enabled: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := &Model{ + Audit: tt.input, + } + got, err := toAuditPayload(t.Context(), m) + if err != nil && !tt.wantErr { + t.Fatalf("unexpected error: %v", err) + } + if err == nil && tt.wantErr { + t.Fatalf("expected error, but got none") + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestVerifySystemComponentNodepools(t *testing.T) { tests := []struct { description string @@ -2754,6 +2815,67 @@ func TestValidateConfig(t *testing.T) { } } +func TestMapAudit(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input *ske.Audit + stateAudit types.Object + want types.Object + wantErr bool + }{ + { + name: "nil audit", + input: nil, + stateAudit: types.ObjectNull(auditTypes), + want: types.ObjectNull(auditTypes), + }, + { + name: "audit enabled", + input: &ske.Audit{ + Enabled: true, + }, + stateAudit: types.ObjectNull(auditTypes), + want: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(true), + }), + }, + { + name: "disabled audit kept when API omits audit", + input: nil, + stateAudit: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(false), + }), + want: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(false), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := &Model{ + Audit: tt.stateAudit, + } + cluster := &ske.Cluster{ + Audit: tt.input, + } + + err := mapAudit(t.Context(), cluster, m) + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantErr && err == nil { + t.Fatalf("expected error, but got none") + } + if diff := cmp.Diff(tt.want, m.Audit); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestMapAccess(t *testing.T) { t.Parallel() tests := []struct { From 9dbda2f97e9363454c580d56a50eae6992b5c1b3 Mon Sep 17 00:00:00 2001 From: Tobias Pfaffelmoser Date: Fri, 10 Jul 2026 14:13:53 +0200 Subject: [PATCH 2/4] docs(ske): clarify mapAudit state handling when API omits audit block --- stackit/internal/services/ske/cluster/resource.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index cb3fec6a8..729807551 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -1996,7 +1996,11 @@ func getMaintenanceTimes(ctx context.Context, cl *ske.Cluster, m *Model) (startT } func mapAudit(ctx context.Context, cl *ske.Cluster, m *Model) error { + // The API may omit the audit block entirely (e.g. when audit logging is not + // enabled or not available for the account). In that case we must decide what + // to keep in the Terraform state without producing a spurious plan diff: if cl.Audit == nil { + // No audit configured on either side (or the value is still unknown): normalize to null. if m.Audit.IsNull() || m.Audit.IsUnknown() || m.Audit.Attributes() == nil { m.Audit = types.ObjectNull(auditTypes) return nil @@ -2008,10 +2012,15 @@ func mapAudit(ctx context.Context, cl *ske.Cluster, m *Model) error { return fmt.Errorf("converting audit object: %v", diags.Errors()) } + // The user explicitly configured `audit = { enabled = false }`. The API does + // not echo a disabled audit config back, so we keep the configured value to + // avoid a permanent diff between config and state. if !auditModel.Enabled.IsUnknown() && !auditModel.Enabled.ValueBool() { return nil } + // Otherwise (configured enabled=true, but API returned nothing) fall back to + // null so the next plan reflects the actual API state. m.Audit = types.ObjectNull(auditTypes) return nil } From 3d628dc0aeb42ec9ff7aa4785ff7e25c246e6994 Mon Sep 17 00:00:00 2001 From: Tobias Pfaffelmoser Date: Fri, 10 Jul 2026 14:34:25 +0200 Subject: [PATCH 3/4] docs(ske): add audit block to ske_cluster example --- examples/resources/stackit_ske_cluster/resource.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/resources/stackit_ske_cluster/resource.tf b/examples/resources/stackit_ske_cluster/resource.tf index 36f980f06..3ea78ce0a 100644 --- a/examples/resources/stackit_ske_cluster/resource.tf +++ b/examples/resources/stackit_ske_cluster/resource.tf @@ -26,4 +26,9 @@ resource "stackit_ske_cluster" "example" { access_scope = "PUBLIC" } } + # Cluster audit log forwarding to a Telemetry Router. + # Private preview: only configurable for enabled accounts. + audit = { + enabled = true + } } \ No newline at end of file From 6644cbd19bbf377d91b825b5637a786c59dee5d7 Mon Sep 17 00:00:00 2001 From: Tobias Pfaffelmoser Date: Fri, 10 Jul 2026 14:45:11 +0200 Subject: [PATCH 4/4] docs(ske): regenerate ske_cluster docs with audit example --- docs/data-sources/ske_cluster.md | 1 - docs/resources/ske_cluster.md | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/ske_cluster.md b/docs/data-sources/ske_cluster.md index 41a23f138..d414f0616 100644 --- a/docs/data-sources/ske_cluster.md +++ b/docs/data-sources/ske_cluster.md @@ -72,7 +72,6 @@ Read-Only: - `enabled` (Boolean) Enable cluster audit log forwarding to a Telemetry Router. - ### Nested Schema for `extensions` diff --git a/docs/resources/ske_cluster.md b/docs/resources/ske_cluster.md index 31257d0f8..0d82d49e8 100644 --- a/docs/resources/ske_cluster.md +++ b/docs/resources/ske_cluster.md @@ -44,6 +44,11 @@ resource "stackit_ske_cluster" "example" { access_scope = "PUBLIC" } } + # Cluster audit log forwarding to a Telemetry Router. + # Private preview: only configurable for enabled accounts. + audit = { + enabled = true + } } ``` @@ -144,7 +149,6 @@ Required: - `enabled` (Boolean) Enable cluster audit log forwarding to a Telemetry Router. - ### Nested Schema for `extensions`