From ecfeaeee5a3d5f7c268bcea48949186cdc484cbb Mon Sep 17 00:00:00 2001 From: Danny Textores Date: Fri, 16 Oct 2026 13:26:16 +0200 Subject: [PATCH] feat: implement dremio access control lists (ACL) --- docs/data-sources/dremio_instance.md | 1 + docs/resources/dremio_instance.md | 4 + .../stackit_dremio_instance/resource.tf | 3 + .../services/dremio/dremio_acc_test.go | 18 +++-- .../services/dremio/instance/datasource.go | 9 ++- .../services/dremio/instance/resource.go | 80 ++++++++++++++++++- .../services/dremio/instance/resource_test.go | 7 +- .../services/dremio/testdata/resource-max.tf | 2 + 8 files changed, 110 insertions(+), 14 deletions(-) diff --git a/docs/data-sources/dremio_instance.md b/docs/data-sources/dremio_instance.md index 647c531c0..361a07286 100644 --- a/docs/data-sources/dremio_instance.md +++ b/docs/data-sources/dremio_instance.md @@ -38,6 +38,7 @@ data "stackit_dremio_instance" "example" { ### Read-Only +- `acl` (List of String) The Access Control List (ACL) defining the IPv4 or IPv6 CIDR ranges allowed to connect to the Dremio instance. An empty list allows access from any address. - `authentication` (Attributes) Dremio instance authentication settings. A change here triggers a Dremio restart and will incur downtime. (see [below for nested schema](#nestedatt--authentication)) - `display_name` (String) The display name is a short name chosen by the user to identify the resource. - `endpoints` (Attributes) The available endpoints of the Dremio instance. (see [below for nested schema](#nestedatt--endpoints)) diff --git a/docs/resources/dremio_instance.md b/docs/resources/dremio_instance.md index 8ce1885e5..8fd9d0937 100644 --- a/docs/resources/dremio_instance.md +++ b/docs/resources/dremio_instance.md @@ -21,6 +21,9 @@ resource "stackit_dremio_instance" "example" { region = "eu01" display_name = "exampleName" description = "Example description" + acl = [ + "0.0.0.0/0" + ] authentication = { type = "local-only" // "oauth" or "azuread" for IDP config @@ -57,6 +60,7 @@ resource "stackit_dremio_instance" "example" { ### Optional +- `acl` (List of String) The Access Control List (ACL) defining the IPv4 or IPv6 CIDR ranges allowed to connect to the Dremio instance. An empty list allows access from any address. - `description` (String) The description is a longer text chosen by the user to provide more context for the resource. - `region` (String) The STACKIT region name the resource is located in. If not defined, the provider region is used. - `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) diff --git a/examples/resources/stackit_dremio_instance/resource.tf b/examples/resources/stackit_dremio_instance/resource.tf index de2ae9ad2..61b8b9069 100644 --- a/examples/resources/stackit_dremio_instance/resource.tf +++ b/examples/resources/stackit_dremio_instance/resource.tf @@ -3,6 +3,9 @@ resource "stackit_dremio_instance" "example" { region = "eu01" display_name = "exampleName" description = "Example description" + acl = [ + "0.0.0.0/0" + ] authentication = { type = "local-only" // "oauth" or "azuread" for IDP config diff --git a/stackit/internal/services/dremio/dremio_acc_test.go b/stackit/internal/services/dremio/dremio_acc_test.go index 9f96af231..b543cb501 100644 --- a/stackit/internal/services/dremio/dremio_acc_test.go +++ b/stackit/internal/services/dremio/dremio_acc_test.go @@ -54,12 +54,13 @@ var testDremioConfigVarsMax = config.Variables{ "project_id": config.StringVariable(testutil.ProjectId), "region": config.StringVariable("eu01"), // Instance - "display_name": config.StringVariable("tfAccDremioMaxInstance"), - "description": config.StringVariable("description"), - "authentication_type": config.StringVariable(string(dremioSdk.AUTHENTICATIONTYPE_OAUTH)), - "authentication_oauth_authority_url": config.StringVariable("oauth-authority-url"), - "authentication_oauth_client_id": config.StringVariable("oauth-client-id"), - "authentication_oauth_client_secret": config.StringVariable("oauth-client-secret"), + "display_name": config.StringVariable("tfAccDremioMaxInstance"), + "description": config.StringVariable("description"), + "acl": config.ListVariable(config.StringVariable("0.0.0.0/0")), + "authentication_type": config.StringVariable(string(dremioSdk.AUTHENTICATIONTYPE_OAUTH)), + "authentication_oauth_authority_url": config.StringVariable("oauth-authority-url"), + "authentication_oauth_client_id": config.StringVariable("oauth-client-id"), + "authentication_oauth_client_secret": config.StringVariable("oauth-client-secret"), "authentication_oauth_client_jwt_claims_user_name": config.StringVariable("oauth-jwt-claim-user"), "authentication_oauth_scope": config.StringVariable("oauth-scope"), "authentication_oauth_parameter_name": config.StringVariable("oauth-parameter-name"), @@ -284,6 +285,7 @@ func TestAccDremioInstanceMax(t *testing.T) { resource.TestCheckResourceAttr(dremioInstanceResource, "region", testutil.Region), resource.TestCheckResourceAttr(dremioInstanceResource, "display_name", testutil.ConvertConfigVariable(testDremioConfigVarsMax["display_name"])), resource.TestCheckResourceAttr(dremioInstanceResource, "description", testutil.ConvertConfigVariable(testDremioConfigVarsMax["description"])), + testutil.CheckListAttr(dremioInstanceResource, "acl", testDremioConfigVarsMax["acl"]), resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.type", testutil.ConvertConfigVariable(testDremioConfigVarsMax["authentication_type"])), @@ -343,6 +345,10 @@ func TestAccDremioInstanceMax(t *testing.T) { dremioInstanceResource, "description", dremioInstanceDataResource, "description", ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "acl", + dremioInstanceDataResource, "acl", + ), resource.TestCheckResourceAttrPair( dremioInstanceResource, "authentication.type", dremioInstanceDataResource, "authentication.type", diff --git a/stackit/internal/services/dremio/instance/datasource.go b/stackit/internal/services/dremio/instance/datasource.go index fce34edc1..8c993865d 100644 --- a/stackit/internal/services/dremio/instance/datasource.go +++ b/stackit/internal/services/dremio/instance/datasource.go @@ -10,11 +10,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1betaapi" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" - - dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1betaapi" ) var ( @@ -220,6 +220,11 @@ func (d *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques }, }, }, + "acl": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + Description: descriptions["acl"], + }, "authentication": schema.SingleNestedAttribute{ Description: descriptions["authentication"], Computed: true, diff --git a/stackit/internal/services/dremio/instance/resource.go b/stackit/internal/services/dremio/instance/resource.go index 7e98da2b2..02e659bb4 100644 --- a/stackit/internal/services/dremio/instance/resource.go +++ b/stackit/internal/services/dremio/instance/resource.go @@ -8,15 +8,19 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" @@ -48,6 +52,7 @@ type Model struct { // Optional Fields Description types.String `tfsdk:"description"` + Acl types.List `tfsdk:"acl"` // Read-only Fields Endpoints types.Object `tfsdk:"endpoints"` // see EndpointsModel @@ -130,6 +135,7 @@ var descriptions = map[string]string{ //nolint:gosec // no hardcoded credentials "endpoints_arrow_flight": "The arrow flight endpoint of the Dremio instance.", "endpoints_catalog": "The Apache Iceberg endpoint of the Dremio instance.", "endpoints_ui": "The UI endpoint of the Dremio instance.", + "acl": "The Access Control List (ACL) defining the IPv4 or IPv6 CIDR ranges allowed to connect to the Dremio instance. An empty list allows access from any address.", "authentication": "Dremio instance authentication settings. A change here triggers a Dremio restart and will incur downtime.", "authentication_type": "Type of authentication (local-only, azuread, oauth).", "azuread": "Azure Active Directory authentication configuration.", @@ -253,6 +259,24 @@ func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest, Description: descriptions["display_name"], Required: true, }, + "acl": schema.ListAttribute{ + Optional: true, + Computed: true, // Required when using Default + ElementType: types.StringType, + // The API returns an empty list for acl even if the field is omitted (null) in the request. + // This causes an "inconsistent result" error in Terraform, because the config is null + // but the state is []. + // + // By setting a Default value of an empty list, we tell Terraform to treat a missing + // acl block in the HCL as if the user explicitly defined acl = []. + // This ensures the config (empty list) matches the API response (empty list). + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Description: descriptions["acl"], + Validators: []validator.List{ + listvalidator.NoNullValues(), + listvalidator.ValueStringsAre(validate.CIDR()), + }, + }, "authentication": schema.SingleNestedAttribute{ Description: descriptions["authentication"], Required: true, @@ -411,7 +435,7 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques ctx = tflog.SetField(ctx, "region", region) // prepare the payload struct for the create instance request - payload, err := toCreatePayload(&model) + payload, err := toCreatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err)) return @@ -535,7 +559,7 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "region", region) - payload, err := toUpdatePayload(&model) + payload, err := toUpdatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Creating API payload: %v", err)) return @@ -702,9 +726,41 @@ func mapModelFields(ctx context.Context, instanceResp *dremioSdk.DremioResponse, } model.Endpoints = endpointsObj + acl, err := mapAcl(ctx, instanceResp.Acl, model.Acl) + if err != nil { + return err + } + model.Acl = acl + return nil } +func mapAcl(ctx context.Context, apiAcl []string, modelAcl types.List) (basetypes.ListValue, error) { + var acl basetypes.ListValue + var diags diag.Diagnostics + + if apiAcl == nil { + acl = types.ListNull(types.StringType) + } else { + var modelAclSlice []string + + if !modelAcl.IsNull() && !modelAcl.IsUnknown() { + diags = modelAcl.ElementsAs(ctx, &modelAclSlice, false) + if diags.HasError() { + return basetypes.ListValue{}, fmt.Errorf("failed to parse acl: %w", core.DiagsToError(diags)) + } + } + aclSlice := utils.ReconcileStringSlices(modelAclSlice, apiAcl) + + acl, diags = types.ListValueFrom(ctx, types.StringType, aclSlice) + if diags.HasError() { + return basetypes.ListValue{}, fmt.Errorf("mapping ACL: %w", core.DiagsToError(diags)) + } + } + + return acl, nil +} + func mapAuthentication(instanceResp *dremioSdk.DremioResponse, auth *AuthenticationModel) error { if instanceResp == nil { return fmt.Errorf("response input is nil") @@ -757,7 +813,7 @@ func mapAuthentication(instanceResp *dremioSdk.DremioResponse, auth *Authenticat } // Build UpdateDremioInstancePayload from provider's model -func toUpdatePayload(model *InstanceModel) (*dremioSdk.UpdateDremioInstancePayload, error) { +func toUpdatePayload(ctx context.Context, model *InstanceModel) (*dremioSdk.UpdateDremioInstancePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -767,7 +823,14 @@ func toUpdatePayload(model *InstanceModel) (*dremioSdk.UpdateDremioInstancePaylo return nil, fmt.Errorf("failed to parse authentication: %w", err) } + var acl []string + diags := model.Acl.ElementsAs(ctx, &acl, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to parse acl: %v", diags) + } + return &dremioSdk.UpdateDremioInstancePayload{ + Acl: acl, Authentication: authentication, Description: model.Description.ValueStringPointer(), DisplayName: model.DisplayName.ValueStringPointer(), @@ -775,7 +838,7 @@ func toUpdatePayload(model *InstanceModel) (*dremioSdk.UpdateDremioInstancePaylo } // Build CreateDremioInstancePayload from provider's model -func toCreatePayload(model *InstanceModel) (*dremioSdk.CreateDremioInstancePayload, error) { +func toCreatePayload(ctx context.Context, model *InstanceModel) (*dremioSdk.CreateDremioInstancePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -785,7 +848,16 @@ func toCreatePayload(model *InstanceModel) (*dremioSdk.CreateDremioInstancePaylo return nil, fmt.Errorf("failed to parse authentication: %w", err) } + var acl []string + if !model.Acl.IsNull() && !model.Acl.IsUnknown() { + diags := model.Acl.ElementsAs(ctx, &acl, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to parse acl: %v", diags) + } + } + return &dremioSdk.CreateDremioInstancePayload{ + Acl: acl, Authentication: authentication, Description: model.Description.ValueStringPointer(), DisplayName: model.DisplayName.ValueString(), diff --git a/stackit/internal/services/dremio/instance/resource_test.go b/stackit/internal/services/dremio/instance/resource_test.go index 28fc45f9f..a702e9be3 100644 --- a/stackit/internal/services/dremio/instance/resource_test.go +++ b/stackit/internal/services/dremio/instance/resource_test.go @@ -35,6 +35,7 @@ func TestMapFields(t *testing.T) { CreateTime: time.Now(), Description: utils.Ptr("minimal-required-values"), DisplayName: "greatName", + Acl: []string{"0.0.0.0/0"}, Authentication: dremioSdk.Authentication{ Azuread: &dremioSdk.Azuread{ AuthorityUrl: "azure-authority", @@ -75,6 +76,8 @@ func TestMapFields(t *testing.T) { DisplayName: types.StringValue("greatName"), Description: types.StringValue("minimal-required-values"), + Acl: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("0.0.0.0/0")}), + Endpoints: types.ObjectValueMust( map[string]attr.Type{ "arrow_flight": types.StringType, @@ -378,7 +381,7 @@ func TestToCreatePayload(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - payload, err := toCreatePayload(tt.state) + payload, err := toCreatePayload(t.Context(), tt.state) if (err != nil) != tt.wantErr { t.Errorf("toCreatePayload error = %v, wantErr %v", err, tt.wantErr) return @@ -609,7 +612,7 @@ func TestToUpdatePayload(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - payload, err := toUpdatePayload(tt.state) + payload, err := toUpdatePayload(t.Context(), tt.state) if (err != nil) != tt.wantErr { t.Errorf("toUpdatePayload error = %v, wantErr %v", err, tt.wantErr) return diff --git a/stackit/internal/services/dremio/testdata/resource-max.tf b/stackit/internal/services/dremio/testdata/resource-max.tf index 7966c246d..1a417ea4c 100644 --- a/stackit/internal/services/dremio/testdata/resource-max.tf +++ b/stackit/internal/services/dremio/testdata/resource-max.tf @@ -5,6 +5,7 @@ variable "region" {} // Instance Variables variable "display_name" {} variable "description" {} +variable "acl" {} variable "authentication_type" {} @@ -36,6 +37,7 @@ resource "stackit_dremio_instance" "example" { region = var.region display_name = var.display_name description = var.description + acl = var.acl authentication = { type = var.authentication_type