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/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_export_policy.md b/docs/resources/sfs_export_policy.md index 08e24c026..8ed85a93f 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 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 572ae0f8d..c99b9c3cf 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 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 57bd194fb..a571bd4b1 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 the resource. - `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only 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..4fe4f9807 100644 --- a/stackit/internal/services/sfs/export-policy/resource.go +++ b/stackit/internal/services/sfs/export-policy/resource.go @@ -47,6 +47,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 +189,12 @@ 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 the resource.", + ElementType: types.StringType, + Optional: true, + Validators: validate.LabelValidators(), + }, "rules": schema.ListNestedAttribute{ Computed: true, Optional: true, @@ -499,6 +506,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 +579,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 +606,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 +626,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 +655,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/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..461be7b41 100644 --- a/stackit/internal/services/sfs/resourcepool/resource.go +++ b/stackit/internal/services/sfs/resourcepool/resource.go @@ -17,6 +17,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 +46,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 +144,12 @@ 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 the resource.", + ElementType: types.StringType, + Optional: true, + Validators: validate.LabelValidators(), + }, "region": schema.StringAttribute{ Optional: true, // must be computed to allow for storing the override value from the provider @@ -500,6 +508,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 +547,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 +580,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..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 } @@ -121,6 +123,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 +134,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 +163,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 } @@ -191,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 @@ -220,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"), ), }, @@ -257,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"), ), }, @@ -299,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 @@ -346,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 @@ -400,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 @@ -429,6 +444,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 +480,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 +523,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,6 +553,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.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.label", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["label"])), ), }, // Data source @@ -570,6 +590,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.%", "1"), + resource.TestCheckResourceAttr("data.stackit_sfs_resource_pool.resource_pool_ds", "labels.label", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMax["label"])), ), }, // Import @@ -612,6 +634,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.%", "1"), + resource.TestCheckResourceAttr("stackit_sfs_resource_pool.resourcepool", "labels.label", testutil.ConvertConfigVariable(testConfigResourcePoolVarsMaxUpdated()["label"])), ), }, // Deletion is done by the framework implicitly @@ -641,6 +665,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 @@ -677,6 +702,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 @@ -723,6 +749,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 @@ -755,6 +782,8 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_export_policy.exportpolicy", "name", ), 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 @@ -794,6 +823,8 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_export_policy.exportpolicy", "name", ), 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 @@ -843,6 +874,8 @@ func TestAccShareResourceMax(t *testing.T) { "stackit_sfs_export_policy.exportpolicy", "name", ), 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/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..41aa04a05 100644 --- a/stackit/internal/services/sfs/share/resource.go +++ b/stackit/internal/services/sfs/share/resource.go @@ -14,6 +14,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" @@ -40,6 +41,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 +161,12 @@ 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 the resource.", + ElementType: types.StringType, + Optional: true, + Validators: validate.LabelValidators(), + }, "region": schema.StringAttribute{ Optional: true, // must be computed to allow for storing the override value from the provider @@ -474,7 +482,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 +504,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 +532,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 +553,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/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 + } } diff --git a/stackit/internal/services/sfs/testdata/resource-pool-max.tf b/stackit/internal/services/sfs/testdata/resource-pool-max.tf index 157cec744..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 @@ -20,5 +21,8 @@ resource "stackit_sfs_resource_pool" "resourcepool" { var.ip_acl_1, var.ip_acl_2 ] + labels = { + 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 ef1667284..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 @@ -27,4 +28,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 = { + label = var.label + } } 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()) + } + }) + } +}