diff --git a/docs/data-sources/ske_cluster.md b/docs/data-sources/ske_cluster.md index c0ead7d66..38a2a1050 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 b1fc8176a..4c72a4dd9 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 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 3d4e9a594..ac0ec5491 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.", + "audit_enabled": "Enable cluster audit log forwarding to a Telemetry Router.", } // Schema defines the schema for the resource. @@ -817,6 +830,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 @@ -1055,6 +1084,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 +1096,7 @@ func (r *clusterResource) createOrUpdateCluster(ctx context.Context, diags *diag } payload := ske.CreateOrUpdateClusterPayload{ + Audit: audit, Extensions: extensions, Hibernation: hibernations, Kubernetes: *kubernetes, @@ -1391,6 +1426,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 +1686,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) @@ -1943,6 +1998,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 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..674e1a5d6 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,75 @@ 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: "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 !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 { diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index 2776c133b..59aa8f466 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -94,6 +94,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{ @@ -113,6 +114,7 @@ func configVarsMaxUpdated() config.Variables { updatedConfig["nodepool_os_version_min"] = config.StringVariable(skeProviderOptions.GetUpdateMachineVersion()) updatedConfig["maintenance_end"] = config.StringVariable("03:03:03+00:00") updatedConfig["access_idp_enabled"] = config.BoolVariable(false) + updatedConfig["audit_enabled"] = config.BoolVariable(false) return updatedConfig } @@ -162,6 +164,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 @@ -191,6 +196,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 @@ -247,6 +253,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( @@ -333,6 +340,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", @@ -410,6 +420,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 @@ -501,6 +514,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 92377aa0e..bab553532 100644 --- a/stackit/internal/services/ske/testdata/resource-max.tf +++ b/stackit/internal/services/ske/testdata/resource-max.tf @@ -37,6 +37,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 @@ -105,6 +106,9 @@ resource "stackit_ske_cluster" "cluster" { type = "stackit" } } + audit = { + enabled = var.audit_enabled + } } resource "stackit_ske_kubeconfig" "kubeconfig" {