From 5a2261ff784c5a53517ccd5c0f8b32974bd1af95 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 27 Apr 2026 18:29:22 +0200 Subject: [PATCH 1/8] 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 d47ab4204..d28930ff5 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 @@ -511,6 +533,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) @@ -538,10 +572,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(), @@ -564,11 +605,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 3cbcacda8..439aff786 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 @@ -485,7 +507,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") } @@ -507,6 +529,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) @@ -523,9 +557,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 @@ -536,9 +578,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 3585ec77c794636bae5dc563a88a71bf5aefe6fe Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 27 Apr 2026 18:37:51 +0200 Subject: [PATCH 2/8] 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 12b2425cd42d58f3f5d9a2fcc0fc649a94bcb434 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 28 Apr 2026 09:20:08 +0200 Subject: [PATCH 3/8] 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 9baea9f06dc8d135b0598bdf7ece0e91c415ce14 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 28 Apr 2026 09:56:39 +0200 Subject: [PATCH 4/8] 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 04280a718..b55c5c420 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, @@ -511,6 +531,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{} @@ -572,6 +604,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 { @@ -593,7 +631,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 @@ -612,6 +651,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 { @@ -635,7 +680,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 86835cd35fcda02d2ba0eca4ba5c291801e4c673 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 28 Apr 2026 09:57:19 +0200 Subject: [PATCH 5/8] 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 1724700ab31deffd1df83c13f6cd3bd172f671bd Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 30 Apr 2026 10:28:02 +0200 Subject: [PATCH 6/8] 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 b55c5c420..718f05486 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 d28930ff5..eb673814d 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 439aff786..0de5d6d3f 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 669785b1da8e57bd380625af174d951167613c71 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 30 Apr 2026 10:41:16 +0200 Subject: [PATCH 7/8] 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 From c405e646c68b03c0283c5c0818ebf9a62abf7963 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 12 May 2026 14:40:51 +0200 Subject: [PATCH 8/8] fixed labels --- .../services/sfs/export-policy/resource.go | 34 ++--- .../sfs/export-policy/resource_test.go | 127 ++++++++++++++++- .../services/sfs/resourcepool/datasource.go | 13 +- .../services/sfs/resourcepool/resource.go | 35 ++--- .../sfs/resourcepool/resource_test.go | 9 +- .../internal/services/sfs/share/datasource.go | 13 +- .../internal/services/sfs/share/resource.go | 39 ++--- .../services/sfs/share/resource_test.go | 10 +- stackit/internal/utils/labels.go | 45 ++++++ stackit/internal/utils/labels_test.go | 133 ++++++++++++++++++ 10 files changed, 360 insertions(+), 98 deletions(-) create mode 100644 stackit/internal/utils/labels.go create mode 100644 stackit/internal/utils/labels_test.go diff --git a/stackit/internal/services/sfs/export-policy/resource.go b/stackit/internal/services/sfs/export-policy/resource.go index 718f05486..e5f9908db 100644 --- a/stackit/internal/services/sfs/export-policy/resource.go +++ b/stackit/internal/services/sfs/export-policy/resource.go @@ -276,7 +276,7 @@ func (r *exportPolicyResource) Create(ctx context.Context, req resource.CreateRe } } - payload, err := toCreatePayload(&model, rules) + payload, err := toCreatePayload(ctx, &model, rules) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating export policy", fmt.Sprintf("Creating API payload: %v", err)) return @@ -407,7 +407,7 @@ func (r *exportPolicyResource) Update(ctx context.Context, req resource.UpdateRe } } - payload, err := toUpdatePayload(&model, rules) + payload, err := toUpdatePayload(ctx, &model, rules) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating export policy", fmt.Sprintf("Creating API payload: %v", err)) return @@ -518,15 +518,9 @@ 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) + labels, err := utils.MapLabels(ctx, resp.ShareExportPolicy.Labels, model.Labels) + if err != nil { + return err } model.Labels = labels @@ -583,7 +577,7 @@ func mapFields(ctx context.Context, resp *sfs.GetShareExportPolicyResponse, mode } // Build a CreateShareExportPolicyPayload from provider's model -func toCreatePayload(model *Model, rules []rulesModel) (*sfs.CreateShareExportPolicyPayload, error) { +func toCreatePayload(ctx context.Context, model *Model, rules []rulesModel) (*sfs.CreateShareExportPolicyPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -591,10 +585,9 @@ func toCreatePayload(model *Model, rules []rulesModel) (*sfs.CreateShareExportPo return nil, fmt.Errorf("nil rules") } - modelLabels := model.Labels.Elements() - labels, err := conversion.ToOptStringMap(modelLabels) + labels, err := utils.LabelsToPayload(ctx, model.Labels) if err != nil { - return nil, fmt.Errorf("converting to Go map: %w", err) + return nil, err } // iterate over rules @@ -619,7 +612,7 @@ func toCreatePayload(model *Model, rules []rulesModel) (*sfs.CreateShareExportPo // name and rules result := &sfs.CreateShareExportPolicyPayload{ Name: model.Name.ValueString(), - Labels: labels, + Labels: &labels, } // Rules should only be set if tempRules has value. Otherwise, the payload would contain `{ "rules": null }` what should be prevented @@ -630,7 +623,7 @@ func toCreatePayload(model *Model, rules []rulesModel) (*sfs.CreateShareExportPo return result, nil } -func toUpdatePayload(model *Model, rules []rulesModel) (*sfs.UpdateShareExportPolicyPayload, error) { +func toUpdatePayload(ctx context.Context, model *Model, rules []rulesModel) (*sfs.UpdateShareExportPolicyPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -638,10 +631,9 @@ func toUpdatePayload(model *Model, rules []rulesModel) (*sfs.UpdateShareExportPo return nil, fmt.Errorf("nil rules") } - modelLabels := model.Labels.Elements() - labels, err := conversion.ToOptStringMap(modelLabels) + labels, err := utils.LabelsToPayload(ctx, model.Labels) if err != nil { - return nil, fmt.Errorf("converting to GO map: %w", err) + return nil, err } // iterate over rules @@ -668,7 +660,7 @@ func toUpdatePayload(model *Model, rules []rulesModel) (*sfs.UpdateShareExportPo // 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, - Labels: labels, + 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 6539c2be8..a8d797e43 100644 --- a/stackit/internal/services/sfs/export-policy/resource_test.go +++ b/stackit/internal/services/sfs/export-policy/resource_test.go @@ -153,14 +153,16 @@ func fixtureRulesPayloadModel() []rulesModel { func fixtureExportPolicyCreatePayload(rules []sfs.CreateShareExportPolicyRequestRule) *sfs.CreateShareExportPolicyPayload { return &sfs.CreateShareExportPolicyPayload{ - Name: "createPayloadName", - Rules: rules, + Name: "createPayloadName", + Rules: rules, + Labels: &map[string]string{}, } } func fixtureExportPolicyUpdatePayload(rules []sfs.UpdateShareExportPolicyBodyRule) *sfs.UpdateShareExportPolicyPayload { return &sfs.UpdateShareExportPolicyPayload{ - Rules: rules, + Rules: rules, + Labels: &map[string]string{}, } } @@ -222,6 +224,84 @@ func TestMapFields(t *testing.T) { region: testRegion, isValid: true, }, + { + name: "Add Labels", + state: &Model{ + ProjectId: types.StringValue(project_id), + }, + input: &sfs.GetShareExportPolicyResponse{ + ShareExportPolicy: &sfs.ShareExportPolicy{ + Id: new("uuid1"), + Rules: fixtureRulesResponse(), + Labels: &map[string]string{ + "foo": "bar", + }, + }, + }, + expectedModel: &Model{ + ProjectId: types.StringValue(project_id), + Id: types.StringValue(project_id + ",region,uuid1"), + ExportPolicyId: types.StringValue("uuid1"), + Rules: fixtureRulesModel(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + Region: types.StringValue("region"), + }, + region: testRegion, + isValid: true, + }, + { + name: "Remove Labels through empty map", + state: &Model{ + ProjectId: types.StringValue(project_id), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + }, + input: &sfs.GetShareExportPolicyResponse{ + ShareExportPolicy: &sfs.ShareExportPolicy{ + Id: new("uuid1"), + Rules: fixtureRulesResponse(), + Labels: &map[string]string{}, + }, + }, + expectedModel: &Model{ + ProjectId: types.StringValue(project_id), + Id: types.StringValue(project_id + ",region,uuid1"), + ExportPolicyId: types.StringValue("uuid1"), + Rules: fixtureRulesModel(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + Region: types.StringValue("region"), + }, + region: testRegion, + isValid: true, + }, + { + name: "Remove Labels through missing parameter", + state: &Model{ + ProjectId: types.StringValue(project_id), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + }, + input: &sfs.GetShareExportPolicyResponse{ + ShareExportPolicy: &sfs.ShareExportPolicy{ + Id: new("uuid1"), + Rules: fixtureRulesResponse(), + }, + }, + expectedModel: &Model{ + ProjectId: types.StringValue(project_id), + Id: types.StringValue(project_id + ",region,uuid1"), + ExportPolicyId: types.StringValue("uuid1"), + Rules: fixtureRulesModel(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + Region: types.StringValue("region"), + }, + region: testRegion, + isValid: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -285,10 +365,29 @@ func TestToCreatePayload(t *testing.T) { expected: fixtureExportPolicyCreatePayload(fixtureRulesCreatePayload()), wantErr: false, }, + { + name: "valid label payload", + model: &Model{ + ProjectId: types.StringValue(project_id), + Name: types.StringValue("createPayloadName"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + }, + rules: fixtureRulesPayloadModel(), + expected: &sfs.CreateShareExportPolicyPayload{ + Name: "createPayloadName", + Rules: fixtureRulesCreatePayload(), + Labels: &map[string]string{ + "foo": "bar", + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toCreatePayload(tt.model, tt.rules) + got, err := toCreatePayload(context.Background(), tt.model, tt.rules) if (err != nil) != tt.wantErr { t.Errorf("toCreatePayload() error = %v, wantErr %v", err, tt.wantErr) return @@ -343,10 +442,28 @@ func TestToUpdatePayload(t *testing.T) { expected: fixtureExportPolicyUpdatePayload(fixtureRulesUpdatePayload()), wantErr: false, }, + { + name: "valid label payload", + model: &Model{ + ProjectId: types.StringValue(project_id), + Name: types.StringValue("createPayloadName"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + }, + rules: fixtureRulesPayloadModel(), + expected: &sfs.UpdateShareExportPolicyPayload{ + Rules: fixtureRulesUpdatePayload(), + Labels: &map[string]string{ + "foo": "bar", + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toUpdatePayload(tt.model, tt.rules) + got, err := toUpdatePayload(context.Background(), tt.model, tt.rules) if (err != nil) != tt.wantErr { t.Errorf("toUpdatePayload() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/stackit/internal/services/sfs/resourcepool/datasource.go b/stackit/internal/services/sfs/resourcepool/datasource.go index cdae620b1..c315691bd 100644 --- a/stackit/internal/services/sfs/resourcepool/datasource.go +++ b/stackit/internal/services/sfs/resourcepool/datasource.go @@ -13,7 +13,6 @@ 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" @@ -255,15 +254,9 @@ 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) + labels, err := utils.MapLabels(ctx, resourcePool.Labels, model.Labels) + if err != nil { + return err } model.Labels = labels diff --git a/stackit/internal/services/sfs/resourcepool/resource.go b/stackit/internal/services/sfs/resourcepool/resource.go index eb673814d..5994606c9 100644 --- a/stackit/internal/services/sfs/resourcepool/resource.go +++ b/stackit/internal/services/sfs/resourcepool/resource.go @@ -17,7 +17,6 @@ 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" @@ -229,7 +228,7 @@ func (r *resourcePoolResource) Create(ctx context.Context, req resource.CreateRe ctx = core.InitProviderContext(ctx) - payload, err := toCreatePayload(&model) + payload, err := toCreatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating resource pool", fmt.Sprintf("Cannot create payload: %v", err)) return @@ -376,7 +375,7 @@ func (r *resourcePoolResource) Update(ctx context.Context, req resource.UpdateRe return } - payload, err := toUpdatePayload(&model) + payload, err := toUpdatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update resource pool", fmt.Sprintf("cannot create payload: %v", err)) return @@ -519,15 +518,9 @@ 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) + labels, err := utils.MapLabels(ctx, resourcePool.Labels, model.Labels) + if err != nil { + return err } model.Labels = labels @@ -543,7 +536,7 @@ func mapFields(ctx context.Context, region string, resourcePool *sfs.ResourcePoo return nil } -func toCreatePayload(model *Model) (*sfs.CreateResourcePoolPayload, error) { +func toCreatePayload(ctx context.Context, model *Model) (*sfs.CreateResourcePoolPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -558,17 +551,16 @@ func toCreatePayload(model *Model) (*sfs.CreateResourcePoolPayload, error) { aclList = tmp } - modelLabels := model.Labels.Elements() - labels, err := conversion.ToOptStringMap(modelLabels) + labels, err := utils.LabelsToPayload(ctx, model.Labels) if err != nil { - return nil, fmt.Errorf("converting to Go map: %w", err) + return nil, err } result := &sfs.CreateResourcePoolPayload{ AvailabilityZone: model.AvailabilityZone.ValueString(), IpAcl: aclList, Name: model.Name.ValueString(), - Labels: labels, + Labels: &labels, PerformanceClass: model.PerformanceClass.ValueString(), SizeGigabytes: model.SizeGigabytes.ValueInt32(), SnapshotsAreVisible: model.SnapshotsAreVisible.ValueBoolPointer(), @@ -576,7 +568,7 @@ func toCreatePayload(model *Model) (*sfs.CreateResourcePoolPayload, error) { return result, nil } -func toUpdatePayload(model *Model) (*sfs.UpdateResourcePoolPayload, error) { +func toUpdatePayload(ctx context.Context, model *Model) (*sfs.UpdateResourcePoolPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -591,10 +583,9 @@ func toUpdatePayload(model *Model) (*sfs.UpdateResourcePoolPayload, error) { aclList = tmp } - modelLabels := model.Labels.Elements() - labels, err := conversion.ToOptStringMap(modelLabels) + labels, err := utils.LabelsToPayload(ctx, model.Labels) if err != nil { - return nil, fmt.Errorf("converting to GO map: %w", err) + return nil, err } result := &sfs.UpdateResourcePoolPayload{ @@ -602,7 +593,7 @@ func toUpdatePayload(model *Model) (*sfs.UpdateResourcePoolPayload, error) { PerformanceClass: model.PerformanceClass.ValueStringPointer(), SizeGigabytes: *sfs.NewNullableInt32(model.SizeGigabytes.ValueInt32Pointer()), SnapshotsAreVisible: model.SnapshotsAreVisible.ValueBoolPointer(), - Labels: labels, + 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 ed9e01f16..8f81a961a 100644 --- a/stackit/internal/services/sfs/resourcepool/resource_test.go +++ b/stackit/internal/services/sfs/resourcepool/resource_test.go @@ -144,6 +144,7 @@ func TestToCreatePayload(t *testing.T) { Name: "testname", PerformanceClass: "performance", SizeGigabytes: 42, + Labels: &map[string]string{}, }, false, }, @@ -165,13 +166,14 @@ func TestToCreatePayload(t *testing.T) { Name: "testname", PerformanceClass: "performance", SizeGigabytes: 42, + Labels: &map[string]string{}, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toCreatePayload(tt.model) + got, err := toCreatePayload(context.Background(), tt.model) if (err != nil) != tt.wantErr { t.Errorf("toCreatePayload() error = %v, wantErr %v", err, tt.wantErr) return @@ -208,6 +210,7 @@ func TestToUpdatePayload(t *testing.T) { PerformanceClass: new("performance"), SizeGigabytes: *sfs.NewNullableInt32(utils.Ptr[int32](42)), SnapshotsAreVisible: new(true), + Labels: &map[string]string{}, }, false, }, @@ -227,6 +230,7 @@ func TestToUpdatePayload(t *testing.T) { IpAcl: nil, PerformanceClass: new("performance"), SizeGigabytes: *sfs.NewNullableInt32(utils.Ptr[int32](42)), + Labels: &map[string]string{}, }, false, }, @@ -246,13 +250,14 @@ func TestToUpdatePayload(t *testing.T) { IpAcl: []string{}, PerformanceClass: new("performance"), SizeGigabytes: *sfs.NewNullableInt32(utils.Ptr[int32](42)), + Labels: &map[string]string{}, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toUpdatePayload(tt.model) + got, err := toUpdatePayload(context.Background(), tt.model) if (err != nil) != tt.wantErr { t.Errorf("toUpdatePayload() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/stackit/internal/services/sfs/share/datasource.go b/stackit/internal/services/sfs/share/datasource.go index bd878d0a0..28899614e 100644 --- a/stackit/internal/services/sfs/share/datasource.go +++ b/stackit/internal/services/sfs/share/datasource.go @@ -10,7 +10,6 @@ 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" @@ -228,15 +227,9 @@ func mapDataSourceFields(ctx context.Context, region string, share *sfs.Share, m 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) + labels, err := utils.MapLabels(ctx, share.Labels, model.Labels) + if err != nil { + return err } model.Labels = labels diff --git a/stackit/internal/services/sfs/share/resource.go b/stackit/internal/services/sfs/share/resource.go index 0de5d6d3f..e5814636b 100644 --- a/stackit/internal/services/sfs/share/resource.go +++ b/stackit/internal/services/sfs/share/resource.go @@ -14,7 +14,6 @@ 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" @@ -227,7 +226,7 @@ func (r *shareResource) Create(ctx context.Context, req resource.CreateRequest, ctx = core.InitProviderContext(ctx) - payload, err := toCreatePayload(&model) + payload, err := toCreatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Create Resourcepool", fmt.Sprintf("Cannot create payload: %v", err)) return @@ -235,7 +234,7 @@ func (r *shareResource) Create(ctx context.Context, req resource.CreateRequest, // Create new share share, err := r.client.DefaultAPI.CreateShare(ctx, projectId, region, resourcePoolId). - CreateSharePayload(payload). + CreateSharePayload(*payload). Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating share", fmt.Sprintf("Calling API: %v", err)) @@ -378,7 +377,7 @@ func (r *shareResource) Update(ctx context.Context, req resource.UpdateRequest, return } - payload, err := toUpdatePayload(&model) + payload, err := toUpdatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update share", fmt.Sprintf("cannot create payload: %v", err)) return @@ -515,15 +514,9 @@ func mapFields(ctx context.Context, share *sfs.Share, region string, model *Mode ) 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) + labels, err := utils.MapLabels(ctx, share.Labels, model.Labels) + if err != nil { + return err } model.Labels = labels @@ -539,41 +532,39 @@ func mapFields(ctx context.Context, share *sfs.Share, region string, model *Mode return nil } -func toCreatePayload(model *Model) (ret sfs.CreateSharePayload, err error) { +func toCreatePayload(ctx context.Context, 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) + labels, err := utils.LabelsToPayload(ctx, model.Labels) if err != nil { - return ret, fmt.Errorf("converting to Go map: %w", err) + return nil, err } - result := sfs.CreateSharePayload{ + result := &sfs.CreateSharePayload{ ExportPolicyName: *sfs.NewNullableString(model.ExportPolicyName.ValueStringPointer()), Name: model.Name.ValueString(), - Labels: labels, + Labels: &labels, SpaceHardLimitGigabytes: model.SpaceHardLimitGigabytes.ValueInt32(), } return result, nil } -func toUpdatePayload(model *Model) (*sfs.UpdateSharePayload, error) { +func toUpdatePayload(ctx context.Context, model *Model) (*sfs.UpdateSharePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - modelLabels := model.Labels.Elements() - labels, err := conversion.ToOptStringMap(modelLabels) + labels, err := utils.LabelsToPayload(ctx, model.Labels) if err != nil { - return nil, fmt.Errorf("converting to GO map: %w", err) + return nil, err } result := &sfs.UpdateSharePayload{ ExportPolicyName: *sfs.NewNullableString(model.ExportPolicyName.ValueStringPointer()), SpaceHardLimitGigabytes: *sfs.NewNullableInt32(model.SpaceHardLimitGigabytes.ValueInt32Pointer()), - Labels: labels, + 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 4f48d6872..7aa7208d1 100644 --- a/stackit/internal/services/sfs/share/resource_test.go +++ b/stackit/internal/services/sfs/share/resource_test.go @@ -114,7 +114,7 @@ func TestToCreatePayload(t *testing.T) { tests := []struct { name string model *Model - want sfs.CreateSharePayload + want *sfs.CreateSharePayload wantErr bool }{ { @@ -128,17 +128,18 @@ func TestToCreatePayload(t *testing.T) { ExportPolicyName: testPolicyName, SpaceHardLimitGigabytes: types.Int32Value(42), }, - sfs.CreateSharePayload{ + &sfs.CreateSharePayload{ ExportPolicyName: *sfs.NewNullableString(new("test-policy")), Name: "testname", SpaceHardLimitGigabytes: 42, + Labels: &map[string]string{}, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toCreatePayload(tt.model) + got, err := toCreatePayload(context.Background(), tt.model) if (err != nil) != tt.wantErr { t.Errorf("toCreatePayload() error = %v, wantErr %v", err, tt.wantErr) return @@ -174,13 +175,14 @@ func TestToUpdatePayload(t *testing.T) { &sfs.UpdateSharePayload{ ExportPolicyName: *sfs.NewNullableString(testPolicyName.ValueStringPointer()), SpaceHardLimitGigabytes: *sfs.NewNullableInt32(utils.Ptr[int32](42)), + Labels: &map[string]string{}, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toUpdatePayload(tt.model) + got, err := toUpdatePayload(context.Background(), tt.model) if (err != nil) != tt.wantErr { t.Errorf("toCreatePayload() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/stackit/internal/utils/labels.go b/stackit/internal/utils/labels.go new file mode 100644 index 000000000..b209af87b --- /dev/null +++ b/stackit/internal/utils/labels.go @@ -0,0 +1,45 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" +) + +func MapLabels(ctx context.Context, responseLabels *map[string]string, currentLabels types.Map) (basetypes.MapValue, error) { // nolint:gocritic // responseLabels needs to be a pointer + // Labels can have a value {"foo": "bar"}, can be empty {} or can be not provided by the config. + // The last two states are identical for the API but have a different tfstate value. + // The goal of this function is to only apply a change to the values if they actually got changed. + labels := types.MapValueMust(types.StringType, map[string]attr.Value{}) + + if responseLabels != nil && len(*responseLabels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *responseLabels) + if diags.HasError() { + return labels, fmt.Errorf("convert labels to string map: %w", core.DiagsToError(diags)) + } + } else if currentLabels.IsNull() { + labels = types.MapNull(types.StringType) + } + + return labels, nil +} + +func LabelsToPayload(ctx context.Context, modelLabels types.Map) (map[string]string, error) { + labels := map[string]string{} + + if !modelLabels.IsNull() { + diags := modelLabels.ElementsAs(ctx, &labels, false) + if diags.HasError() { + return nil, fmt.Errorf("converting from MapValue: %w", core.DiagsToError(diags)) + } + } + + return labels, nil +} diff --git a/stackit/internal/utils/labels_test.go b/stackit/internal/utils/labels_test.go new file mode 100644 index 000000000..a931c30c3 --- /dev/null +++ b/stackit/internal/utils/labels_test.go @@ -0,0 +1,133 @@ +package utils + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestMapLabels(t *testing.T) { + type args struct { + currentLabels types.Map + responseLabels *map[string]string + } + tests := []struct { + name string + input args + expectedOutput basetypes.MapValue + isValid bool + }{ + { + name: "No labels, no map", + input: args{ + currentLabels: types.MapNull(types.StringType), + responseLabels: &map[string]string{}, + }, + expectedOutput: types.MapNull(types.StringType), + isValid: true, + }, + { + name: "No labels, empty map", + input: args{ + currentLabels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + responseLabels: &map[string]string{}, + }, + expectedOutput: types.MapValueMust(types.StringType, map[string]attr.Value{}), + isValid: true, + }, + { + name: "Add Labels", + input: args{ + currentLabels: types.MapNull(types.StringType), + responseLabels: &map[string]string{ + "foo": "bar", + }, + }, + expectedOutput: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + isValid: true, + }, + { + name: "Remove Labels", + input: args{ + currentLabels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + responseLabels: &map[string]string{}, + }, + expectedOutput: types.MapValueMust(types.StringType, map[string]attr.Value{}), + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := MapLabels(context.Background(), tt.input.responseLabels, tt.input.currentLabels) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestLabelsToPayload(t *testing.T) { + tests := []struct { + name string + input types.Map + expectedOutput map[string]string + isValid bool + }{ + { + name: "No labels, no map", + input: types.MapNull(types.StringType), + expectedOutput: map[string]string{}, + isValid: true, + }, + { + name: "No labels, empty map", + input: types.MapValueMust(types.StringType, map[string]attr.Value{}), + expectedOutput: map[string]string{}, + isValid: true, + }, + { + name: "Valid Labels", + input: types.MapValueMust(types.StringType, map[string]attr.Value{ + "foo": types.StringValue("bar"), + }), + expectedOutput: map[string]string{ + "foo": "bar", + }, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := LabelsToPayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +}