From 2b0cfb0957f0599cab69d65f411c427295d06921 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 27 Apr 2026 18:29:22 +0200 Subject: [PATCH 1/7] feat(sfs): add labels to share and resource pool relates to STACKITTPR-525 --- .../services/sfs/resourcepool/datasource.go | 22 +++++++- .../sfs/resourcepool/datasource_test.go | 2 + .../services/sfs/resourcepool/resource.go | 48 +++++++++++++++++ .../sfs/resourcepool/resource_test.go | 2 + stackit/internal/services/sfs/sfs_acc_test.go | 6 +++ .../internal/services/sfs/share/datasource.go | 21 +++++++- .../services/sfs/share/datasource_test.go | 1 + .../internal/services/sfs/share/resource.go | 51 ++++++++++++++++++- .../services/sfs/share/resource_test.go | 2 + .../sfs/testdata/resource-pool-max.tf | 3 ++ .../services/sfs/testdata/share-max.tf | 3 ++ 11 files changed, 158 insertions(+), 3 deletions(-) diff --git a/stackit/internal/services/sfs/resourcepool/datasource.go b/stackit/internal/services/sfs/resourcepool/datasource.go index ca45b86bf..cdae620b1 100644 --- a/stackit/internal/services/sfs/resourcepool/datasource.go +++ b/stackit/internal/services/sfs/resourcepool/datasource.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "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" sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" @@ -43,6 +44,7 @@ type dataSourceModel struct { PerformanceClassDowngradableAt types.String `tfsdk:"performance_class_downgradable_at"` Region types.String `tfsdk:"region"` SnapshotsAreVisible types.Bool `tfsdk:"snapshots_are_visible"` + Labels types.Map `tfsdk:"labels"` } type resourcePoolDataSource struct { @@ -195,7 +197,13 @@ func (r *resourcePoolDataSource) Schema(_ context.Context, _ datasource.SchemaRe // the region cannot be found automatically, so it has to be passed Optional: true, Description: "The resource region. Read-only attribute that reflects the provider region.", - }}, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource pool", + ElementType: types.StringType, + Computed: true, + }, + }, } } @@ -247,5 +255,17 @@ func mapDataSourceFields(ctx context.Context, region string, resourcePool *sfs.R model.SizeReducibleAt = types.StringValue(t.Format(time.RFC3339)) } + var labels basetypes.MapValue + if resourcePool.Labels != nil && len(*resourcePool.Labels) != 0 { + var err error + labels, err = conversion.ToTerraformStringMap(ctx, *resourcePool.Labels) + if err != nil { + return fmt.Errorf("converting to StringValue map: %w", err) + } + } else { + labels = types.MapNull(types.StringType) + } + model.Labels = labels + return nil } diff --git a/stackit/internal/services/sfs/resourcepool/datasource_test.go b/stackit/internal/services/sfs/resourcepool/datasource_test.go index 3785dc8c3..2ab9407fd 100644 --- a/stackit/internal/services/sfs/resourcepool/datasource_test.go +++ b/stackit/internal/services/sfs/resourcepool/datasource_test.go @@ -41,6 +41,7 @@ func TestMapDatasourceFields(t *testing.T) { AvailabilityZone: types.StringNull(), IpAcl: types.ListNull(types.StringType), Name: types.StringNull(), + Labels: types.MapNull(types.StringType), PerformanceClass: types.StringNull(), SizeGigabytes: types.Int32Null(), Region: testRegion, @@ -87,6 +88,7 @@ func TestMapDatasourceFields(t *testing.T) { types.StringValue("baz"), }), Name: types.StringValue("testname"), + Labels: types.MapNull(types.StringType), PerformanceClass: types.StringValue("performance"), SizeGigabytes: types.Int32Value(42), Region: testRegion, diff --git a/stackit/internal/services/sfs/resourcepool/resource.go b/stackit/internal/services/sfs/resourcepool/resource.go index f80bdcc19..9e4f5761b 100644 --- a/stackit/internal/services/sfs/resourcepool/resource.go +++ b/stackit/internal/services/sfs/resourcepool/resource.go @@ -6,9 +6,12 @@ import ( "errors" "fmt" "net/http" + "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -17,6 +20,7 @@ import ( "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" sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" @@ -45,6 +49,7 @@ type Model struct { AvailabilityZone types.String `tfsdk:"availability_zone"` IpAcl types.List `tfsdk:"ip_acl"` Name types.String `tfsdk:"name"` + Labels types.Map `tfsdk:"labels"` PerformanceClass types.String `tfsdk:"performance_class"` SizeGigabytes types.Int32 `tfsdk:"size_gigabytes"` Region types.String `tfsdk:"region"` @@ -142,6 +147,23 @@ func (r *resourcePoolResource) Schema(_ context.Context, _ resource.SchemaReques validate.NoSeparator(), }, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a instance.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + mapvalidator.KeysAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), + "must match expression"), + ), + mapvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), + "must match expression"), + ), + }, + }, "region": schema.StringAttribute{ Optional: true, // must be computed to allow for storing the override value from the provider @@ -500,6 +522,18 @@ func mapFields(ctx context.Context, region string, resourcePool *sfs.ResourcePoo model.IpAcl = types.ListNull(types.StringType) } + var labels basetypes.MapValue + if resourcePool.Labels != nil && len(*resourcePool.Labels) != 0 { + var err error + labels, err = conversion.ToTerraformStringMap(ctx, *resourcePool.Labels) + if err != nil { + return fmt.Errorf("converting to StringValue map: %w", err) + } + } else { + labels = types.MapNull(types.StringType) + } + model.Labels = labels + model.Name = types.StringPointerValue(resourcePool.Name) if pc := resourcePool.PerformanceClass; pc != nil { model.PerformanceClass = types.StringPointerValue(pc.Name) @@ -527,10 +561,17 @@ func toCreatePayload(model *Model) (*sfs.CreateResourcePoolPayload, error) { aclList = tmp } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + result := &sfs.CreateResourcePoolPayload{ AvailabilityZone: model.AvailabilityZone.ValueString(), IpAcl: aclList, Name: model.Name.ValueString(), + Labels: labels, PerformanceClass: model.PerformanceClass.ValueString(), SizeGigabytes: model.SizeGigabytes.ValueInt32(), SnapshotsAreVisible: model.SnapshotsAreVisible.ValueBoolPointer(), @@ -553,11 +594,18 @@ func toUpdatePayload(model *Model) (*sfs.UpdateResourcePoolPayload, error) { aclList = tmp } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to GO map: %w", err) + } + result := &sfs.UpdateResourcePoolPayload{ IpAcl: aclList, PerformanceClass: model.PerformanceClass.ValueStringPointer(), SizeGigabytes: *sfs.NewNullableInt32(model.SizeGigabytes.ValueInt32Pointer()), SnapshotsAreVisible: model.SnapshotsAreVisible.ValueBoolPointer(), + Labels: labels, } return result, nil } diff --git a/stackit/internal/services/sfs/resourcepool/resource_test.go b/stackit/internal/services/sfs/resourcepool/resource_test.go index a78a793e7..ed9e01f16 100644 --- a/stackit/internal/services/sfs/resourcepool/resource_test.go +++ b/stackit/internal/services/sfs/resourcepool/resource_test.go @@ -51,6 +51,7 @@ func TestMapFields(t *testing.T) { AvailabilityZone: types.StringNull(), IpAcl: types.ListNull(types.StringType), Name: types.StringNull(), + Labels: types.MapNull(types.StringType), PerformanceClass: types.StringNull(), SizeGigabytes: types.Int32Null(), Region: testRegion, @@ -95,6 +96,7 @@ func TestMapFields(t *testing.T) { types.StringValue("baz"), }), Name: types.StringValue("testname"), + Labels: types.MapNull(types.StringType), PerformanceClass: types.StringValue("performance"), SizeGigabytes: types.Int32Value(42), Region: testRegion, diff --git a/stackit/internal/services/sfs/sfs_acc_test.go b/stackit/internal/services/sfs/sfs_acc_test.go index 546df69ea..8c109a6b7 100644 --- a/stackit/internal/services/sfs/sfs_acc_test.go +++ b/stackit/internal/services/sfs/sfs_acc_test.go @@ -535,6 +535,7 @@ func TestAccResourcePoolResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_1"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_2"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "snapshots_are_visible", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["snapshots_are_visible"])), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.foo", "bar"), ), }, // Data source @@ -570,6 +571,7 @@ func TestAccResourcePoolResourceMax(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_1"])), resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_2"])), resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "snapshots_are_visible", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["snapshots_are_visible"])), + resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "labels.foo", "bar"), ), }, // Import @@ -612,6 +614,7 @@ func TestAccResourcePoolResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["ip_acl_1"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["ip_acl_2"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "snapshots_are_visible", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["snapshots_are_visible"])), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.foo", "bar"), ), }, // Deletion is done by the framework implicitly @@ -754,6 +757,7 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_share.share", "export_policy", "stackit_sfs_export_policy.exportpolicy", "name", ), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.foo", "bar"), resource.TestCheckResourceAttrSet("stackit_sfs_share.share", "mount_path"), ), }, @@ -793,6 +797,7 @@ func TestAccShareResourceMax(t *testing.T) { "data.stackit_sfs_share.share_ds", "export_policy", "stackit_sfs_export_policy.exportpolicy", "name", ), + resource.TestCheckResourceAttr("data.stackit_sfs_share.share_ds", "labels.foo", "bar"), resource.TestCheckResourceAttrSet("data.stackit_sfs_share.share_ds", "mount_path"), ), }, @@ -842,6 +847,7 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_share.share", "export_policy", "stackit_sfs_export_policy.exportpolicy", "name", ), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.foo", "bar"), resource.TestCheckResourceAttrSet("stackit_sfs_share.share", "mount_path"), ), }, diff --git a/stackit/internal/services/sfs/share/datasource.go b/stackit/internal/services/sfs/share/datasource.go index a1c4a9cc9..bd878d0a0 100644 --- a/stackit/internal/services/sfs/share/datasource.go +++ b/stackit/internal/services/sfs/share/datasource.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "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" sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" @@ -37,6 +38,7 @@ type dataSourceModel struct { SpaceHardLimitGigabytes types.Int32 `tfsdk:"space_hard_limit_gigabytes"` ExportPolicyName types.String `tfsdk:"export_policy"` Region types.String `tfsdk:"region"` + Labels types.Map `tfsdk:"labels"` } type shareDataSource struct { client *sfs.APIClient @@ -183,11 +185,16 @@ You can also assign a Share Export Policy after creating the Share`, Optional: true, Description: "The resource region. Read-only attribute that reflects the provider region.", }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a share", + ElementType: types.StringType, + Computed: true, + }, }, } } -func mapDataSourceFields(_ context.Context, region string, share *sfs.Share, model *dataSourceModel) error { +func mapDataSourceFields(ctx context.Context, region string, share *sfs.Share, model *dataSourceModel) error { if share == nil { return fmt.Errorf("share empty in response") } @@ -221,5 +228,17 @@ func mapDataSourceFields(_ context.Context, region string, share *sfs.Share, mod model.MountPath = types.StringPointerValue(share.MountPath) + var labels basetypes.MapValue + if share.Labels != nil && len(*share.Labels) != 0 { + var err error + labels, err = conversion.ToTerraformStringMap(ctx, *share.Labels) + if err != nil { + return fmt.Errorf("converting to StringValue map: %w", err) + } + } else { + labels = types.MapNull(types.StringType) + } + model.Labels = labels + return nil } diff --git a/stackit/internal/services/sfs/share/datasource_test.go b/stackit/internal/services/sfs/share/datasource_test.go index 533f6454e..3750ed7e6 100644 --- a/stackit/internal/services/sfs/share/datasource_test.go +++ b/stackit/internal/services/sfs/share/datasource_test.go @@ -43,6 +43,7 @@ func TestMapDatasourceFields(t *testing.T) { ResourcePoolId: testResourcePoolId, ShareId: testShareId, Name: types.StringValue("test-name"), + Labels: types.MapNull(types.StringType), ExportPolicyName: testPolicyName, SpaceHardLimitGigabytes: types.Int32Value(42), MountPath: types.StringValue("/testmount"), diff --git a/stackit/internal/services/sfs/share/resource.go b/stackit/internal/services/sfs/share/resource.go index d591a2302..a202ee29d 100644 --- a/stackit/internal/services/sfs/share/resource.go +++ b/stackit/internal/services/sfs/share/resource.go @@ -6,14 +6,18 @@ import ( "errors" "fmt" "net/http" + "regexp" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "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" sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" @@ -40,6 +44,7 @@ type Model struct { ResourcePoolId types.String `tfsdk:"resource_pool_id"` ShareId types.String `tfsdk:"share_id"` Name types.String `tfsdk:"name"` + Labels types.Map `tfsdk:"labels"` ExportPolicyName types.String `tfsdk:"export_policy"` SpaceHardLimitGigabytes types.Int32 `tfsdk:"space_hard_limit_gigabytes"` Region types.String `tfsdk:"region"` @@ -159,6 +164,23 @@ func (r *shareResource) Schema(_ context.Context, _ resource.SchemaRequest, resp validate.NoSeparator(), }, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a instance.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + mapvalidator.KeysAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), + "must match expression"), + ), + mapvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), + "must match expression"), + ), + }, + }, "region": schema.StringAttribute{ Optional: true, // must be computed to allow for storing the override value from the provider @@ -474,7 +496,7 @@ func (r *shareResource) ImportState(ctx context.Context, req resource.ImportStat tflog.Info(ctx, "SFS share imported") } -func mapFields(_ context.Context, share *sfs.Share, region string, model *Model) error { +func mapFields(ctx context.Context, share *sfs.Share, region string, model *Model) error { if share == nil { return fmt.Errorf("share empty in response") } @@ -496,6 +518,18 @@ func mapFields(_ context.Context, share *sfs.Share, region string, model *Model) ) model.Name = types.StringPointerValue(share.Name) + var labels basetypes.MapValue + if share.Labels != nil && len(*share.Labels) != 0 { + var err error + labels, err = conversion.ToTerraformStringMap(ctx, *share.Labels) + if err != nil { + return fmt.Errorf("converting to StringValue map: %w", err) + } + } else { + labels = types.MapNull(types.StringType) + } + model.Labels = labels + if share.ExportPolicy.IsSet() { if policy := share.ExportPolicy.Get(); policy != nil { model.ExportPolicyName = types.StringPointerValue(policy.Name) @@ -512,9 +546,17 @@ func toCreatePayload(model *Model) (ret sfs.CreateSharePayload, err error) { if model == nil { return ret, fmt.Errorf("nil model") } + + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return ret, fmt.Errorf("converting to Go map: %w", err) + } + result := sfs.CreateSharePayload{ ExportPolicyName: *sfs.NewNullableString(model.ExportPolicyName.ValueStringPointer()), Name: model.Name.ValueString(), + Labels: labels, SpaceHardLimitGigabytes: model.SpaceHardLimitGigabytes.ValueInt32(), } return result, nil @@ -525,9 +567,16 @@ func toUpdatePayload(model *Model) (*sfs.UpdateSharePayload, error) { return nil, fmt.Errorf("nil model") } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to GO map: %w", err) + } + result := &sfs.UpdateSharePayload{ ExportPolicyName: *sfs.NewNullableString(model.ExportPolicyName.ValueStringPointer()), SpaceHardLimitGigabytes: *sfs.NewNullableInt32(model.SpaceHardLimitGigabytes.ValueInt32Pointer()), + Labels: labels, } return result, nil } diff --git a/stackit/internal/services/sfs/share/resource_test.go b/stackit/internal/services/sfs/share/resource_test.go index 907fe8e8f..4f48d6872 100644 --- a/stackit/internal/services/sfs/share/resource_test.go +++ b/stackit/internal/services/sfs/share/resource_test.go @@ -54,6 +54,7 @@ func TestMapFields(t *testing.T) { ResourcePoolId: testResourcePoolId, ShareId: testShareId, Name: types.StringValue("testname"), + Labels: types.MapNull(types.StringType), ExportPolicyName: testPolicyName, SpaceHardLimitGigabytes: types.Int32Value(42), Region: types.StringValue("eu01"), @@ -84,6 +85,7 @@ func TestMapFields(t *testing.T) { ProjectId: testProjectId, ResourcePoolId: testResourcePoolId, Name: types.StringValue("testname"), + Labels: types.MapNull(types.StringType), ShareId: testShareId, ExportPolicyName: testPolicyName, SpaceHardLimitGigabytes: types.Int32Value(42), diff --git a/stackit/internal/services/sfs/testdata/resource-pool-max.tf b/stackit/internal/services/sfs/testdata/resource-pool-max.tf index 157cec744..4c9a3d07b 100644 --- a/stackit/internal/services/sfs/testdata/resource-pool-max.tf +++ b/stackit/internal/services/sfs/testdata/resource-pool-max.tf @@ -20,5 +20,8 @@ resource "stackit_sfs_resource_pool" "resourcepool" { var.ip_acl_1, var.ip_acl_2 ] + labels = { + foo = "bar" + } snapshots_are_visible = var.snapshots_are_visible } diff --git a/stackit/internal/services/sfs/testdata/share-max.tf b/stackit/internal/services/sfs/testdata/share-max.tf index ef1667284..b56838d49 100644 --- a/stackit/internal/services/sfs/testdata/share-max.tf +++ b/stackit/internal/services/sfs/testdata/share-max.tf @@ -27,4 +27,7 @@ resource "stackit_sfs_share" "share" { name = var.name export_policy = stackit_sfs_export_policy.exportpolicy.name space_hard_limit_gigabytes = var.space_hard_limit_gigabytes + labels = { + foo = "bar" + } } From 6762b2b8ee9ee93f17cc370af26595a1b6e45780 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 27 Apr 2026 18:37:51 +0200 Subject: [PATCH 2/7] generate docu --- docs/data-sources/sfs_resource_pool.md | 1 + docs/data-sources/sfs_share.md | 1 + docs/resources/sfs_resource_pool.md | 1 + docs/resources/sfs_share.md | 1 + 4 files changed, 4 insertions(+) diff --git a/docs/data-sources/sfs_resource_pool.md b/docs/data-sources/sfs_resource_pool.md index 6d0036947..8edd1531a 100644 --- a/docs/data-sources/sfs_resource_pool.md +++ b/docs/data-sources/sfs_resource_pool.md @@ -39,6 +39,7 @@ data "stackit_sfs_resource_pool" "resourcepool" { - `availability_zone` (String) Availability zone. - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`resource_pool_id`". - `ip_acl` (List of String) List of IPs that can mount the resource pool in read-only; IPs must have a subnet mask (e.g. "172.16.0.0/24" for a range of IPs, or "172.16.0.250/32" for a specific IP). +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource pool - `name` (String) Name of the resource pool. - `performance_class` (String) Name of the performance class. - `performance_class_downgradable_at` (String) Time when the performance class can be downgraded again. diff --git a/docs/data-sources/sfs_share.md b/docs/data-sources/sfs_share.md index 6cf5979f7..6c99ca7b4 100644 --- a/docs/data-sources/sfs_share.md +++ b/docs/data-sources/sfs_share.md @@ -43,6 +43,7 @@ Note that if this is not set, the Share can only be mounted in read only by clients with IPs matching the IP ACL of the Resource Pool hosting this Share. You can also assign a Share Export Policy after creating the Share - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`share_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a share - `mount_path` (String) Mount path of the Share, used to mount the Share - `name` (String) Name of the Share - `space_hard_limit_gigabytes` (Number) Space hard limit for the Share. diff --git a/docs/resources/sfs_resource_pool.md b/docs/resources/sfs_resource_pool.md index 572ae0f8d..5a1ff814d 100644 --- a/docs/resources/sfs_resource_pool.md +++ b/docs/resources/sfs_resource_pool.md @@ -50,6 +50,7 @@ import { ### Optional +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a instance. - `region` (String) The resource region. If not defined, the provider region is used. - `snapshots_are_visible` (Boolean) If set to true, snapshots are visible and accessible to users. (default: false) diff --git a/docs/resources/sfs_share.md b/docs/resources/sfs_share.md index 57bd194fb..0d42d4cab 100644 --- a/docs/resources/sfs_share.md +++ b/docs/resources/sfs_share.md @@ -49,6 +49,7 @@ import { Note that if this is set to an empty string, the Share can only be mounted in read only by clients with IPs matching the IP ACL of the Resource Pool hosting this Share. You can also assign a Share Export Policy after creating the Share +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a instance. - `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only From a7e58ca5e355562e0f86266673aedb546fc04b17 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 28 Apr 2026 09:20:08 +0200 Subject: [PATCH 3/7] improved acc tests --- stackit/internal/services/sfs/sfs_acc_test.go | 28 +++++++++++++++---- .../sfs/testdata/resource-pool-max.tf | 3 +- .../services/sfs/testdata/share-max.tf | 3 +- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/stackit/internal/services/sfs/sfs_acc_test.go b/stackit/internal/services/sfs/sfs_acc_test.go index 8c109a6b7..bec49368b 100644 --- a/stackit/internal/services/sfs/sfs_acc_test.go +++ b/stackit/internal/services/sfs/sfs_acc_test.go @@ -121,6 +121,7 @@ var testConfigResourcePoolVarsMax = config.Variables{ "performance_class": config.StringVariable("Standard"), "size_gigabytes": config.IntegerVariable(512), "snapshots_are_visible": config.BoolVariable(true), + "label": config.StringVariable("foo"), } var testConfigResourcePoolVarsMaxUpdated = func() config.Variables { @@ -131,6 +132,7 @@ var testConfigResourcePoolVarsMaxUpdated = func() config.Variables { updatedConfig["size_gigabytes"] = config.IntegerVariable(1024) updatedConfig["ip_acl_1"] = config.StringVariable("172.17.0.0/24") updatedConfig["ip_acl_2"] = config.StringVariable("172.17.0.250/32") + updatedConfig["label"] = config.StringVariable("bar") return updatedConfig } @@ -159,12 +161,14 @@ var testConfigShareVarsMax = config.Variables{ "resource_pool_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), "space_hard_limit_gigabytes": config.IntegerVariable(42), "export_policy_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), + "label": config.StringVariable("foo"), } var testConfigShareVarsMaxUpdated = func() config.Variables { updatedConfig := config.Variables{} maps.Copy(updatedConfig, testConfigShareVarsMax) updatedConfig["space_hard_limit_gigabytes"] = config.IntegerVariable(50) + updatedConfig["label"] = config.StringVariable("bar") return updatedConfig } @@ -429,6 +433,7 @@ func TestAccResourcePoolResourceMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMin["ip_acl_1"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMin["ip_acl_2"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "snapshots_are_visible", "false"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.%", "0"), ), }, // Data source @@ -464,6 +469,7 @@ func TestAccResourcePoolResourceMin(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMin["ip_acl_1"])), resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMin["ip_acl_2"])), resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "snapshots_are_visible", "false"), + resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "labels.%", "0"), ), }, // Import @@ -506,6 +512,7 @@ func TestAccResourcePoolResourceMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMinUpdated()["ip_acl_1"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMinUpdated()["ip_acl_2"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "snapshots_are_visible", "false"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.%", "0"), ), }, // Deletion is done by the framework implicitly @@ -535,7 +542,8 @@ func TestAccResourcePoolResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_1"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_2"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "snapshots_are_visible", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["snapshots_are_visible"])), - resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.foo", "bar"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.label", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["label"])), ), }, // Data source @@ -571,7 +579,8 @@ func TestAccResourcePoolResourceMax(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_1"])), resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["ip_acl_2"])), resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "snapshots_are_visible", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["snapshots_are_visible"])), - resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "labels.foo", "bar"), + resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "labels.label", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["label"])), ), }, // Import @@ -614,7 +623,8 @@ func TestAccResourcePoolResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.0", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["ip_acl_1"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "ip_acl.1", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["ip_acl_2"])), resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "snapshots_are_visible", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["snapshots_are_visible"])), - resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.foo", "bar"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.label", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["label"])), ), }, // Deletion is done by the framework implicitly @@ -644,6 +654,7 @@ func TestAccShareResourceMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_share.share", "space_hard_limit_gigabytes", testutil.ConvertConfigVariable(testConfigShareVarsMin["space_hard_limit_gigabytes"])), resource.TestCheckNoResourceAttr("stackit_sfs_share.share", "export_policy"), resource.TestCheckResourceAttrSet("stackit_sfs_share.share", "mount_path"), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.%", "0"), ), }, // Data source @@ -680,6 +691,7 @@ func TestAccShareResourceMin(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_sfs_share.share_ds", "space_hard_limit_gigabytes", testutil.ConvertConfigVariable(testConfigShareVarsMin["space_hard_limit_gigabytes"])), resource.TestCheckNoResourceAttr("data.stackit_sfs_share.share_ds", "export_policy"), resource.TestCheckResourceAttrSet("data.stackit_sfs_share.share_ds", "mount_path"), + resource.TestCheckResourceAttr("data.stackit_sfs_share.share_ds", "labels.%", "0"), ), }, // Import @@ -726,6 +738,7 @@ func TestAccShareResourceMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_share.share", "space_hard_limit_gigabytes", testutil.ConvertConfigVariable(testConfigShareVarsMinUpdated()["space_hard_limit_gigabytes"])), resource.TestCheckNoResourceAttr("stackit_sfs_share.share", "export_policy"), resource.TestCheckResourceAttrSet("stackit_sfs_share.share", "mount_path"), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.%", "0"), ), }, // Deletion is done by the framework implicitly @@ -757,8 +770,9 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_share.share", "export_policy", "stackit_sfs_export_policy.exportpolicy", "name", ), - resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.foo", "bar"), resource.TestCheckResourceAttrSet("stackit_sfs_share.share", "mount_path"), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.label", testutil.ConvertConfigVariable(testConfigShareVarsMax["label"])), ), }, // Data source @@ -797,8 +811,9 @@ func TestAccShareResourceMax(t *testing.T) { "data.stackit_sfs_share.share_ds", "export_policy", "stackit_sfs_export_policy.exportpolicy", "name", ), - resource.TestCheckResourceAttr("data.stackit_sfs_share.share_ds", "labels.foo", "bar"), resource.TestCheckResourceAttrSet("data.stackit_sfs_share.share_ds", "mount_path"), + resource.TestCheckResourceAttr("data.stackit_sfs_share.share_ds", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_sfs_share.share_ds", "labels.label", testutil.ConvertConfigVariable(testConfigShareVarsMax["label"])), ), }, // Import @@ -847,8 +862,9 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_share.share", "export_policy", "stackit_sfs_export_policy.exportpolicy", "name", ), - resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.foo", "bar"), resource.TestCheckResourceAttrSet("stackit_sfs_share.share", "mount_path"), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_share.share", "labels.label", testutil.ConvertConfigVariable(testConfigShareVarsMaxUpdated()["label"])), ), }, // Deletion is done by the framework implicitly diff --git a/stackit/internal/services/sfs/testdata/resource-pool-max.tf b/stackit/internal/services/sfs/testdata/resource-pool-max.tf index 4c9a3d07b..493f6c2fd 100644 --- a/stackit/internal/services/sfs/testdata/resource-pool-max.tf +++ b/stackit/internal/services/sfs/testdata/resource-pool-max.tf @@ -8,6 +8,7 @@ variable "size_gigabytes" {} variable "ip_acl_1" {} variable "ip_acl_2" {} variable "snapshots_are_visible" {} +variable "label" {} resource "stackit_sfs_resource_pool" "resourcepool" { project_id = var.project_id @@ -21,7 +22,7 @@ resource "stackit_sfs_resource_pool" "resourcepool" { var.ip_acl_2 ] labels = { - foo = "bar" + label = var.label } snapshots_are_visible = var.snapshots_are_visible } diff --git a/stackit/internal/services/sfs/testdata/share-max.tf b/stackit/internal/services/sfs/testdata/share-max.tf index b56838d49..81d85ad93 100644 --- a/stackit/internal/services/sfs/testdata/share-max.tf +++ b/stackit/internal/services/sfs/testdata/share-max.tf @@ -5,6 +5,7 @@ variable "resource_pool_name" {} variable "export_policy_name" {} variable "name" {} variable "space_hard_limit_gigabytes" {} +variable "label" {} resource "stackit_sfs_resource_pool" "resourcepool" { project_id = var.project_id @@ -28,6 +29,6 @@ resource "stackit_sfs_share" "share" { export_policy = stackit_sfs_export_policy.exportpolicy.name space_hard_limit_gigabytes = var.space_hard_limit_gigabytes labels = { - foo = "bar" + label = var.label } } From 0609a9a613ea49270a3e8155dc52082d60a7f928 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 28 Apr 2026 09:56:39 +0200 Subject: [PATCH 4/7] add labels for export policy --- .../services/sfs/export-policy/datasource.go | 5 ++ .../services/sfs/export-policy/resource.go | 50 ++++++++++++++++++- .../sfs/export-policy/resource_test.go | 1 + stackit/internal/services/sfs/sfs_acc_test.go | 11 ++++ .../sfs/testdata/export-policy-max.tf | 4 ++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/stackit/internal/services/sfs/export-policy/datasource.go b/stackit/internal/services/sfs/export-policy/datasource.go index 8d937a279..5de7df03d 100644 --- a/stackit/internal/services/sfs/export-policy/datasource.go +++ b/stackit/internal/services/sfs/export-policy/datasource.go @@ -142,6 +142,11 @@ func (d *exportPolicyDataSource) Schema(_ context.Context, _ datasource.SchemaRe Description: "Name of the export policy.", Computed: true, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource pool", + ElementType: types.StringType, + Computed: true, + }, "rules": schema.ListNestedAttribute{ Computed: true, NestedObject: schema.NestedAttributeObject{ diff --git a/stackit/internal/services/sfs/export-policy/resource.go b/stackit/internal/services/sfs/export-policy/resource.go index ddcfc393a..12e992830 100644 --- a/stackit/internal/services/sfs/export-policy/resource.go +++ b/stackit/internal/services/sfs/export-policy/resource.go @@ -6,9 +6,11 @@ import ( "errors" "fmt" "net/http" + "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -47,6 +49,7 @@ type Model struct { ProjectId types.String `tfsdk:"project_id"` ExportPolicyId types.String `tfsdk:"policy_id"` Name types.String `tfsdk:"name"` + Labels types.Map `tfsdk:"labels"` Rules types.List `tfsdk:"rules"` Region types.String `tfsdk:"region"` } @@ -188,6 +191,23 @@ func (r *exportPolicyResource) Schema(_ context.Context, _ resource.SchemaReques stringvalidator.LengthAtLeast(1), }, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a instance.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + mapvalidator.KeysAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), + "must match expression"), + ), + mapvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), + "must match expression"), + ), + }, + }, "rules": schema.ListNestedAttribute{ Computed: true, Optional: true, @@ -499,6 +519,18 @@ func mapFields(ctx context.Context, resp *sfs.GetShareExportPolicyResponse, mode return fmt.Errorf("export policy id not present") } + var labels basetypes.MapValue + if resp.ShareExportPolicy.Labels != nil && len(*resp.ShareExportPolicy.Labels) != 0 { + var err error + labels, err = conversion.ToTerraformStringMap(ctx, *resp.ShareExportPolicy.Labels) + if err != nil { + return fmt.Errorf("converting to StringValue map: %w", err) + } + } else { + labels = types.MapNull(types.StringType) + } + model.Labels = labels + // iterate over Rules from response if resp.ShareExportPolicy.Rules != nil { rulesList := []attr.Value{} @@ -560,6 +592,12 @@ func toCreatePayload(model *Model, rules []rulesModel) (*sfs.CreateShareExportPo return nil, fmt.Errorf("nil rules") } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + // iterate over rules var tempRules []sfs.CreateShareExportPolicyRequestRule for _, rule := range rules { @@ -581,7 +619,8 @@ func toCreatePayload(model *Model, rules []rulesModel) (*sfs.CreateShareExportPo // name and rules result := &sfs.CreateShareExportPolicyPayload{ - Name: model.Name.ValueString(), + Name: model.Name.ValueString(), + Labels: labels, } // Rules should only be set if tempRules has value. Otherwise, the payload would contain `{ "rules": null }` what should be prevented @@ -600,6 +639,12 @@ func toUpdatePayload(model *Model, rules []rulesModel) (*sfs.UpdateShareExportPo return nil, fmt.Errorf("nil rules") } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to GO map: %w", err) + } + // iterate over rules tempRules := make([]sfs.UpdateShareExportPolicyBodyRule, len(rules)) for i, rule := range rules { @@ -623,7 +668,8 @@ func toUpdatePayload(model *Model, rules []rulesModel) (*sfs.UpdateShareExportPo result := &sfs.UpdateShareExportPolicyPayload{ // Rules should *+never** result in a payload where they are defined as null, e.g. `{ "rules": null }`. Instead, // they should either be set to an array (with values or empty) or they shouldn't be present in the payload. - Rules: tempRules, + Rules: tempRules, + Labels: labels, } return result, nil } diff --git a/stackit/internal/services/sfs/export-policy/resource_test.go b/stackit/internal/services/sfs/export-policy/resource_test.go index 4be266dd1..6539c2be8 100644 --- a/stackit/internal/services/sfs/export-policy/resource_test.go +++ b/stackit/internal/services/sfs/export-policy/resource_test.go @@ -71,6 +71,7 @@ func fixtureResponseModel(rulesModel basetypes.ListValue) *Model { Id: types.StringValue(project_id + ",region,uuid1"), ExportPolicyId: types.StringValue("uuid1"), Rules: rulesModel, + Labels: types.MapNull(types.StringType), Region: types.StringValue("region"), } } diff --git a/stackit/internal/services/sfs/sfs_acc_test.go b/stackit/internal/services/sfs/sfs_acc_test.go index bec49368b..f62db16ad 100644 --- a/stackit/internal/services/sfs/sfs_acc_test.go +++ b/stackit/internal/services/sfs/sfs_acc_test.go @@ -74,6 +74,7 @@ var testConfigExportPolicyVarsMax = config.Variables{ "second_rule_ip_acl_2": config.StringVariable("172.16.0.250/32"), "second_rule_read_only": config.BoolVariable(true), "second_rule_super_user": config.BoolVariable(false), + "label": config.StringVariable("foo"), } var testConfigExportPolicyVarsMaxUpdated = func() config.Variables { @@ -83,6 +84,7 @@ var testConfigExportPolicyVarsMaxUpdated = func() config.Variables { updatedConfig["first_rule_description"] = config.StringVariable("Some other description") updatedConfig["first_rule_ip_acl_1"] = config.StringVariable("172.17.0.0/24") updatedConfig["first_rule_ip_acl_2"] = config.StringVariable("172.17.0.250/32") + updatedConfig["label"] = config.StringVariable("bar") return updatedConfig } @@ -195,6 +197,7 @@ func TestAccExportPolicyMin(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "name", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMin["name"])), resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.#", "0"), + resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "labels.%", "0"), ), }, // Data source @@ -224,6 +227,7 @@ func TestAccExportPolicyMin(t *testing.T) { ), resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "name", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMin["name"])), + resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "labels.%", "0"), resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "rules.#", "0"), ), }, @@ -261,6 +265,7 @@ func TestAccExportPolicyMin(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_sfs_export_policy.exportpolicy", "policy_id"), resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "name", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMinUpdated()["name"])), + resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "labels.%", "0"), resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.#", "0"), ), }, @@ -303,6 +308,8 @@ func TestAccExportPolicyMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.1.read_only", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMax["second_rule_read_only"])), resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.1.set_uuid", "false"), // default value resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.1.super_user", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMax["second_rule_super_user"])), + resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "labels.label", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMax["label"])), ), }, // Data source @@ -350,6 +357,8 @@ func TestAccExportPolicyMax(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "rules.1.read_only", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMax["second_rule_read_only"])), resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "rules.1.set_uuid", "false"), // default value resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "rules.1.super_user", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMax["second_rule_super_user"])), + resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_sfs_export_policy.policy_data_test", "labels.label", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMax["label"])), ), }, // Import @@ -404,6 +413,8 @@ func TestAccExportPolicyMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.1.read_only", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMaxUpdated()["second_rule_read_only"])), resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.1.set_uuid", "false"), // default value resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "rules.1.super_user", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMaxUpdated()["second_rule_super_user"])), + resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_export_policy.exportpolicy", "labels.label", testutil.ConvertConfigVariable(testConfigExportPolicyVarsMaxUpdated()["label"])), ), }, // Deletion is done by the framework implicitly diff --git a/stackit/internal/services/sfs/testdata/export-policy-max.tf b/stackit/internal/services/sfs/testdata/export-policy-max.tf index 7fd85b02b..7b089e923 100644 --- a/stackit/internal/services/sfs/testdata/export-policy-max.tf +++ b/stackit/internal/services/sfs/testdata/export-policy-max.tf @@ -10,6 +10,7 @@ variable "second_rule_ip_acl_1" {} variable "second_rule_ip_acl_2" {} variable "second_rule_read_only" {} variable "second_rule_super_user" {} +variable "label" {} resource "stackit_sfs_export_policy" "exportpolicy" { project_id = var.project_id @@ -32,4 +33,7 @@ resource "stackit_sfs_export_policy" "exportpolicy" { read_only = var.second_rule_read_only super_user = var.second_rule_super_user }] + labels = { + label = var.label + } } From 6b8c3f8fb0cf2f09f64f588ea24dba69ec2ae648 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 28 Apr 2026 09:57:19 +0200 Subject: [PATCH 5/7] generate docu --- docs/data-sources/sfs_export_policy.md | 1 + docs/resources/sfs_export_policy.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/data-sources/sfs_export_policy.md b/docs/data-sources/sfs_export_policy.md index e60538044..0259fd021 100644 --- a/docs/data-sources/sfs_export_policy.md +++ b/docs/data-sources/sfs_export_policy.md @@ -37,6 +37,7 @@ data "stackit_sfs_export_policy" "example" { ### Read-Only - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`policy_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource pool - `name` (String) Name of the export policy. - `rules` (Attributes List) (see [below for nested schema](#nestedatt--rules)) diff --git a/docs/resources/sfs_export_policy.md b/docs/resources/sfs_export_policy.md index 08e24c026..7f4c45ec0 100644 --- a/docs/resources/sfs_export_policy.md +++ b/docs/resources/sfs_export_policy.md @@ -44,6 +44,7 @@ import { ### Optional +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a instance. - `region` (String) The resource region. If not defined, the provider region is used. - `rules` (Attributes List) (see [below for nested schema](#nestedatt--rules)) From 1c8736531be8c4f2f2fa01f5f4910014160e8af8 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 30 Apr 2026 10:28:02 +0200 Subject: [PATCH 6/7] Added label validators --- .../services/sfs/export-policy/resource.go | 17 +-- .../services/sfs/resourcepool/resource.go | 18 +-- .../internal/services/sfs/share/resource.go | 18 +-- stackit/internal/validate/labels.go | 36 +++++ stackit/internal/validate/labels_test.go | 144 ++++++++++++++++++ 5 files changed, 186 insertions(+), 47 deletions(-) create mode 100644 stackit/internal/validate/labels.go create mode 100644 stackit/internal/validate/labels_test.go diff --git a/stackit/internal/services/sfs/export-policy/resource.go b/stackit/internal/services/sfs/export-policy/resource.go index 12e992830..4fe4f9807 100644 --- a/stackit/internal/services/sfs/export-policy/resource.go +++ b/stackit/internal/services/sfs/export-policy/resource.go @@ -6,11 +6,9 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -192,21 +190,10 @@ func (r *exportPolicyResource) Schema(_ context.Context, _ resource.SchemaReques }, }, "labels": schema.MapAttribute{ - Description: "Labels are key-value string pairs which can be attached to a instance.", + Description: "Labels are key-value string pairs which can be attached to the resource.", ElementType: types.StringType, Optional: true, - Validators: []validator.Map{ - mapvalidator.KeysAre( - stringvalidator.RegexMatches( - regexp.MustCompile(`[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), - "must match expression"), - ), - mapvalidator.ValueStringsAre( - stringvalidator.RegexMatches( - regexp.MustCompile(`^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), - "must match expression"), - ), - }, + Validators: validate.LabelValidators(), }, "rules": schema.ListNestedAttribute{ Computed: true, diff --git a/stackit/internal/services/sfs/resourcepool/resource.go b/stackit/internal/services/sfs/resourcepool/resource.go index 9e4f5761b..461be7b41 100644 --- a/stackit/internal/services/sfs/resourcepool/resource.go +++ b/stackit/internal/services/sfs/resourcepool/resource.go @@ -6,12 +6,9 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -148,21 +145,10 @@ func (r *resourcePoolResource) Schema(_ context.Context, _ resource.SchemaReques }, }, "labels": schema.MapAttribute{ - Description: "Labels are key-value string pairs which can be attached to a instance.", + Description: "Labels are key-value string pairs which can be attached to the resource.", ElementType: types.StringType, Optional: true, - Validators: []validator.Map{ - mapvalidator.KeysAre( - stringvalidator.RegexMatches( - regexp.MustCompile(`[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), - "must match expression"), - ), - mapvalidator.ValueStringsAre( - stringvalidator.RegexMatches( - regexp.MustCompile(`^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), - "must match expression"), - ), - }, + Validators: validate.LabelValidators(), }, "region": schema.StringAttribute{ Optional: true, diff --git a/stackit/internal/services/sfs/share/resource.go b/stackit/internal/services/sfs/share/resource.go index a202ee29d..41aa04a05 100644 --- a/stackit/internal/services/sfs/share/resource.go +++ b/stackit/internal/services/sfs/share/resource.go @@ -6,11 +6,8 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" - "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -165,21 +162,10 @@ func (r *shareResource) Schema(_ context.Context, _ resource.SchemaRequest, resp }, }, "labels": schema.MapAttribute{ - Description: "Labels are key-value string pairs which can be attached to a instance.", + Description: "Labels are key-value string pairs which can be attached to the resource.", ElementType: types.StringType, Optional: true, - Validators: []validator.Map{ - mapvalidator.KeysAre( - stringvalidator.RegexMatches( - regexp.MustCompile(`[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), - "must match expression"), - ), - mapvalidator.ValueStringsAre( - stringvalidator.RegexMatches( - regexp.MustCompile(`^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`), - "must match expression"), - ), - }, + Validators: validate.LabelValidators(), }, "region": schema.StringAttribute{ Optional: true, diff --git a/stackit/internal/validate/labels.go b/stackit/internal/validate/labels.go new file mode 100644 index 000000000..c049b71e7 --- /dev/null +++ b/stackit/internal/validate/labels.go @@ -0,0 +1,36 @@ +package validate + +import ( + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func LabelValidators() []validator.Map { + return []validator.Map{ + mapvalidator.KeysAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^.{1,63}$`), + "must be between 1 and 63 characters long"), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[-A-Za-z0-9_.]*$`), + "may only include alphanumerical characters, dashes, underscores and dots"), + stringvalidator.RegexMatches( + regexp.MustCompile(`^([A-Za-z0-9].*)?[A-Za-z0-9]$`), + "must begin and end with an alphanumerical character"), + ), + mapvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^.{0,63}$`), + "must not be longer than 63 characters"), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[-A-Za-z0-9_.]*$`), + "may only include alphanumerical characters, dashes, underscores and dots"), + stringvalidator.RegexMatches( + regexp.MustCompile(`^(([A-Za-z0-9].*)?[A-Za-z0-9])?$`), + "must begin and end with an alphanumerical character"), + ), + } +} diff --git a/stackit/internal/validate/labels_test.go b/stackit/internal/validate/labels_test.go new file mode 100644 index 000000000..36120f687 --- /dev/null +++ b/stackit/internal/validate/labels_test.go @@ -0,0 +1,144 @@ +package validate + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestLabelValidators(t *testing.T) { + tests := []struct { + description string + input map[string]attr.Value + isValid bool + }{ + { + "ok", + map[string]attr.Value{ + "foo": types.StringValue("bar"), + }, + true, + }, + { + "all valid characters", + map[string]attr.Value{ + "abcdefghijklmnopqrstuvwxyz-_.0123456789": types.StringValue("abcdefghijklmnopqrstuvwxyz-_.0123456789"), + }, + true, + }, + { + "invalid character in key", + map[string]attr.Value{ + "foo!1": types.StringValue("bar"), + }, + false, + }, + { + "invalid start in key", + map[string]attr.Value{ + "_foo": types.StringValue("bar"), + }, + false, + }, + { + "invalid end in key", + map[string]attr.Value{ + "foo_": types.StringValue("bar"), + }, + false, + }, + { + "invalid character in value", + map[string]attr.Value{ + "foo": types.StringValue("bar!1"), + }, + false, + }, + { + "invalid start in value", + map[string]attr.Value{ + "foo": types.StringValue("_bar"), + }, + false, + }, + { + "invalid end in value", + map[string]attr.Value{ + "foo": types.StringValue("bar_"), + }, + false, + }, + { + "Max key length", + map[string]attr.Value{ + "123456789012345678901234567890123456789012345678901234567890123": types.StringValue("bar"), + }, + true, + }, + { + "Min key length", + map[string]attr.Value{ + "1": types.StringValue("bar"), + }, + true, + }, + { + "Key to long", + map[string]attr.Value{ + "1234567890123456789012345678901234567890123456789012345678901234": types.StringValue("bar"), + }, + false, + }, + { + "Key to short", + map[string]attr.Value{ + "": types.StringValue("bar"), + }, + false, + }, + { + "Max value length", + map[string]attr.Value{ + "foo": types.StringValue("123456789012345678901234567890123456789012345678901234567890123"), + }, + true, + }, + { + "Empty value", + map[string]attr.Value{ + "foo": types.StringValue(""), + }, + true, + }, + { + "Value to long", + map[string]attr.Value{ + "foo": types.StringValue("1234567890123456789012345678901234567890123456789012345678901234"), + }, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + r := validator.MapResponse{} + + value, _ := types.MapValue(types.StringType, tt.input) + + for _, LabelValidator := range LabelValidators() { + LabelValidator.ValidateMap(context.Background(), validator.MapRequest{ + ConfigValue: value, + }, &r) + } + + if !tt.isValid && !r.Diagnostics.HasError() { + t.Fatalf("Should have failed") + } + if tt.isValid && r.Diagnostics.HasError() { + t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) + } + }) + } +} From a0caac417152abbe226e3cf3d880bf42a63e9947 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 30 Apr 2026 10:41:16 +0200 Subject: [PATCH 7/7] generate docu --- docs/resources/sfs_export_policy.md | 2 +- docs/resources/sfs_resource_pool.md | 2 +- docs/resources/sfs_share.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/resources/sfs_export_policy.md b/docs/resources/sfs_export_policy.md index 7f4c45ec0..8ed85a93f 100644 --- a/docs/resources/sfs_export_policy.md +++ b/docs/resources/sfs_export_policy.md @@ -44,7 +44,7 @@ import { ### Optional -- `labels` (Map of String) Labels are key-value string pairs which can be attached to a instance. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to the resource. - `region` (String) The resource region. If not defined, the provider region is used. - `rules` (Attributes List) (see [below for nested schema](#nestedatt--rules)) diff --git a/docs/resources/sfs_resource_pool.md b/docs/resources/sfs_resource_pool.md index 5a1ff814d..c99b9c3cf 100644 --- a/docs/resources/sfs_resource_pool.md +++ b/docs/resources/sfs_resource_pool.md @@ -50,7 +50,7 @@ import { ### Optional -- `labels` (Map of String) Labels are key-value string pairs which can be attached to a instance. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to the resource. - `region` (String) The resource region. If not defined, the provider region is used. - `snapshots_are_visible` (Boolean) If set to true, snapshots are visible and accessible to users. (default: false) diff --git a/docs/resources/sfs_share.md b/docs/resources/sfs_share.md index 0d42d4cab..a571bd4b1 100644 --- a/docs/resources/sfs_share.md +++ b/docs/resources/sfs_share.md @@ -49,7 +49,7 @@ import { Note that if this is set to an empty string, the Share can only be mounted in read only by clients with IPs matching the IP ACL of the Resource Pool hosting this Share. You can also assign a Share Export Policy after creating the Share -- `labels` (Map of String) Labels are key-value string pairs which can be attached to a instance. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to the resource. - `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only