diff --git a/docs/data-sources/ske_cluster.md b/docs/data-sources/ske_cluster.md index 2687bb5cf..e79bf2f96 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. (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,14 @@ 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 b34254d9e..e77aa329c 100644 --- a/docs/resources/ske_cluster.md +++ b/docs/resources/ske_cluster.md @@ -38,6 +38,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 + } } ``` @@ -54,6 +59,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. (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. @@ -129,6 +135,14 @@ Optional: + +### Nested Schema for `audit` + +Optional: + +- `enabled` (Boolean) Enable cluster audit log forwarding to a Telemetry Router. + + ### Nested Schema for `extensions` diff --git a/examples/resources/stackit_ske_cluster/resource.tf b/examples/resources/stackit_ske_cluster/resource.tf index 35d3faac6..90f8b7fb7 100644 --- a/examples/resources/stackit_ske_cluster/resource.tf +++ b/examples/resources/stackit_ske_cluster/resource.tf @@ -20,4 +20,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 diff --git a/stackit/internal/services/ske/cluster/datasource.go b/stackit/internal/services/ske/cluster/datasource.go index dae8717b4..c5461488f 100644 --- a/stackit/internal/services/ske/cluster/datasource.go +++ b/stackit/internal/services/ske/cluster/datasource.go @@ -343,6 +343,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 c2d2051ec..360d4ecdf 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"` @@ -284,6 +285,16 @@ var dnsTypes = map[string]attr.Type{ "gateway_api": basetypes.BoolType{}, } +// 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{} @@ -426,6 +437,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.", + "audit_enabled": "Enable cluster audit log forwarding to a Telemetry Router.", } // Schema defines the schema for the resource. @@ -846,6 +859,22 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, }, + "audit": schema.SingleNestedAttribute{ + Description: descriptions["audit"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: descriptions["audit_enabled"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + }, + }, "region": schema.StringAttribute{ Optional: true, // must be computed to allow for storing the override value from the provider @@ -1084,6 +1113,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)) @@ -1091,6 +1125,7 @@ func (r *clusterResource) createOrUpdateCluster(ctx context.Context, diags *diag } payload := ske.CreateOrUpdateClusterPayload{ + Audit: audit, Extensions: extensions, Hibernation: hibernations, Kubernetes: *kubernetes, @@ -1420,6 +1455,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 @@ -1680,6 +1731,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(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) @@ -1988,6 +2043,25 @@ func getMaintenanceTimes(ctx context.Context, cl *ske.Cluster, m *Model) (startT return startTime, endTime, nil } +func mapAudit(cl *ske.Cluster, m *Model) error { + // A missing audit block only occurs in regions where the feature is + // unavailable; normalize it to null there. + if cl.Audit == 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, applicationLoadBalancerDisabled 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 ec6ad006e..3bb452b19 100644 --- a/stackit/internal/services/ske/cluster/resource_test.go +++ b/stackit/internal/services/ske/cluster/resource_test.go @@ -837,6 +837,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) @@ -2442,6 +2445,60 @@ func TestToNetworkPayload(t *testing.T) { } } +func TestToAuditPayload(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input types.Object + want *ske.Audit + }{ + { + 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 { + t.Fatalf("unexpected error: %v", err) + } + 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 @@ -2817,6 +2874,71 @@ 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 + }{ + { + 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: "audit disabled echoed by API", + input: &ske.Audit{ + Enabled: false, + }, + stateAudit: types.ObjectNull(auditTypes), + want: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(false), + }), + }, + { + name: "null when API omits audit despite state value", + input: nil, + stateAudit: types.ObjectValueMust(auditTypes, map[string]attr.Value{ + "enabled": types.BoolValue(false), + }), + want: types.ObjectNull(auditTypes), + }, + } + + 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(cluster, m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + 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 { diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index b898f9df6..2ac4d7bec 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -96,6 +96,7 @@ var testConfigVarsMax = config.Variables{ "dns_name": config.StringVariable("acc-" + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha) + ".runs.onstackit.cloud"), "network_control_plane_access_scope": config.StringVariable("PUBLIC"), "access_idp_enabled": config.BoolVariable(true), + "audit_enabled": config.BoolVariable(true), } var testConfigDatasource = config.Variables{ @@ -116,6 +117,7 @@ func configVarsMaxUpdated() config.Variables { updatedConfig["maintenance_end"] = config.StringVariable("03:03:03+00:00") updatedConfig["access_idp_enabled"] = config.BoolVariable(false) updatedConfig["ext_application_load_balancer_enabled"] = config.BoolVariable(false) + updatedConfig["audit_enabled"] = config.BoolVariable(false) return updatedConfig } @@ -165,6 +167,9 @@ func TestAccSKEMin(t *testing.T) { // Access: resource-min does not define an access block, we expect idp: { enabled: false, type: stackit } here because of the default resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.enabled", "false"), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.type", "stackit"), + + // Audit: resource-min does not define an audit block, we expect enabled: false here because of the default + resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "audit.enabled", "false"), ), }, // 2) Data source @@ -194,6 +199,7 @@ func TestAccSKEMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "maintenance.end", testutil.ConvertConfigVariable(testConfigVarsMax["maintenance_end"])), resource.TestCheckResourceAttrSet("stackit_ske_cluster.cluster", "region"), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "network.control_plane.access_scope", testutil.ConvertConfigVariable(testConfigVarsMin["network_control_plane_access_scope"])), + resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "audit.enabled", "false"), ), }, // 3) Import cluster @@ -250,6 +256,7 @@ func TestAccSKEMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "region", testutil.ConvertConfigVariable(configVarsMinUpdated()["region"])), resource.TestCheckResourceAttrSet("stackit_ske_cluster.cluster", "kubernetes_version_used"), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "network.control_plane.access_scope", testutil.ConvertConfigVariable(configVarsMinUpdated()["network_control_plane_access_scope"])), + resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "audit.enabled", "false"), // Kubeconfig resource.TestCheckResourceAttrPair( @@ -338,6 +345,9 @@ func TestAccSKEMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.enabled", testutil.ConvertConfigVariable(testConfigVarsMax["access_idp_enabled"])), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.type", "stackit"), + // Audit + resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "audit.enabled", testutil.ConvertConfigVariable(testConfigVarsMax["audit_enabled"])), + // Kubeconfig resource.TestCheckResourceAttrPair( "stackit_ske_kubeconfig.kubeconfig", "project_id", @@ -417,6 +427,9 @@ func TestAccSKEMax(t *testing.T) { // Access resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "access.idp.enabled", testutil.ConvertConfigVariable(testConfigVarsMax["access_idp_enabled"])), resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "access.idp.type", "stackit"), + + // Audit + resource.TestCheckResourceAttr("data.stackit_ske_cluster.cluster", "audit.enabled", testutil.ConvertConfigVariable(testConfigVarsMax["audit_enabled"])), ), }, // 3) Import cluster @@ -510,6 +523,9 @@ func TestAccSKEMax(t *testing.T) { // Access resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.enabled", testutil.ConvertConfigVariable(configVarsMaxUpdated()["access_idp_enabled"])), resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "access.idp.type", "stackit"), + + // Audit: updated from true to false + resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "audit.enabled", testutil.ConvertConfigVariable(configVarsMaxUpdated()["audit_enabled"])), ), }, // Deletion is done by the framework implicitly diff --git a/stackit/internal/services/ske/testdata/resource-max.tf b/stackit/internal/services/ske/testdata/resource-max.tf index 54ac080a7..07f36a471 100644 --- a/stackit/internal/services/ske/testdata/resource-max.tf +++ b/stackit/internal/services/ske/testdata/resource-max.tf @@ -39,6 +39,7 @@ variable "dns_zone_name" {} variable "dns_name" {} variable "network_control_plane_access_scope" {} variable "access_idp_enabled" {} +variable "audit_enabled" {} resource "stackit_ske_cluster" "cluster" { project_id = var.project_id @@ -111,6 +112,9 @@ resource "stackit_ske_cluster" "cluster" { type = "stackit" } } + audit = { + enabled = var.audit_enabled + } } resource "stackit_ske_kubeconfig" "kubeconfig" {