From f8ba8d0e3fd6e6f45a1cd4667d877450a0eb37d3 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 29 Aug 2025 13:56:53 +0200 Subject: [PATCH 1/7] feat(resourcemanager): add folder resource/datasource Signed-off-by: Mauritz Uphoff --- docs/data-sources/resourcemanager_folder.md | 33 + docs/resources/resourcemanager_folder.md | 42 ++ .../data-source.tf | 3 + .../resource.tf | 5 + .../resourcemanager/folder/datasource.go | 166 +++++ .../resourcemanager/folder/resource.go | 456 ++++++++++++++ .../resourcemanager/folder/resource_test.go | 486 +++++++++++++++ .../resourcemanager_acc_test.go | 576 ++++++++++++++---- .../testdata/resource-folder.tf | 12 + .../testdata/resource-project.tf | 12 + stackit/internal/testutil/testutil.go | 25 + stackit/provider.go | 3 + 12 files changed, 1691 insertions(+), 128 deletions(-) create mode 100644 docs/data-sources/resourcemanager_folder.md create mode 100644 docs/resources/resourcemanager_folder.md create mode 100644 examples/data-sources/stackit_resourcemanager_folder/data-source.tf create mode 100644 examples/resources/stackit_resourcemanager_folder/resource.tf create mode 100644 stackit/internal/services/resourcemanager/folder/datasource.go create mode 100644 stackit/internal/services/resourcemanager/folder/resource.go create mode 100644 stackit/internal/services/resourcemanager/folder/resource_test.go create mode 100644 stackit/internal/services/resourcemanager/testdata/resource-folder.tf create mode 100644 stackit/internal/services/resourcemanager/testdata/resource-project.tf diff --git a/docs/data-sources/resourcemanager_folder.md b/docs/data-sources/resourcemanager_folder.md new file mode 100644 index 000000000..ab9994d05 --- /dev/null +++ b/docs/data-sources/resourcemanager_folder.md @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_resourcemanager_folder Data Source - stackit" +subcategory: "" +description: |- + Resource Manager folder data source schema. To identify the folder, you need to provider the container_id. +--- + +# stackit_resourcemanager_folder (Data Source) + +Resource Manager folder data source schema. To identify the folder, you need to provider the container_id. + +## Example Usage + +```terraform +data "stackit_resourcemanager_folder" "example" { + container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `container_id` (String) Folder container ID. Globally unique, user-friendly identifier. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}. +- `name` (String) The name of the folder. +- `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported. diff --git a/docs/resources/resourcemanager_folder.md b/docs/resources/resourcemanager_folder.md new file mode 100644 index 000000000..3c25d371f --- /dev/null +++ b/docs/resources/resourcemanager_folder.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_resourcemanager_folder Resource - stackit" +subcategory: "" +description: |- + Resource Manager folder resource schema. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_resourcemanager_folder (Resource) + +Resource Manager folder resource schema. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_resourcemanager_folder" "example" { + name = "foo" + owner_email = "foo.bar@stackit.cloud" + parent_container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the folder. +- `owner_email` (String) Email address of the owner of the folder. This value is only considered during creation. Changing it afterwards will have no effect. +- `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported. + +### Optional + +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}. + +### Read-Only + +- `container_id` (String) Folder container ID. Globally unique, user-friendly identifier. +- `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". diff --git a/examples/data-sources/stackit_resourcemanager_folder/data-source.tf b/examples/data-sources/stackit_resourcemanager_folder/data-source.tf new file mode 100644 index 000000000..a91313e9f --- /dev/null +++ b/examples/data-sources/stackit_resourcemanager_folder/data-source.tf @@ -0,0 +1,3 @@ +data "stackit_resourcemanager_folder" "example" { + container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/resources/stackit_resourcemanager_folder/resource.tf b/examples/resources/stackit_resourcemanager_folder/resource.tf new file mode 100644 index 000000000..693c2e419 --- /dev/null +++ b/examples/resources/stackit_resourcemanager_folder/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_resourcemanager_folder" "example" { + name = "foo" + owner_email = "foo.bar@stackit.cloud" + parent_container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/stackit/internal/services/resourcemanager/folder/datasource.go b/stackit/internal/services/resourcemanager/folder/datasource.go new file mode 100644 index 000000000..b0f02fb77 --- /dev/null +++ b/stackit/internal/services/resourcemanager/folder/datasource.go @@ -0,0 +1,166 @@ +package folder + +import ( + "context" + "fmt" + "net/http" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "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-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + resourcemanagerUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &folderDataSource{} +) + +// NewFolderDataSource is a helper function to simplify the provider implementation. +func NewFolderDataSource() datasource.DataSource { + return &folderDataSource{} +} + +// folderDataSource is the data source implementation. +type folderDataSource struct { + client *resourcemanager.APIClient +} + +// Metadata returns the data source type name. +func (d *folderDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_resourcemanager_folder" +} + +func (d *folderDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_resourcemanager_folder", "datasource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := resourcemanagerUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + d.client = apiClient + tflog.Info(ctx, "Resource Manager client configured") +} + +// Schema defines the schema for the data source. +func (d *folderDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Resource Manager folder data source schema. To identify the folder, you need to provider the container_id.", + "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", + "container_id": "Folder container ID. Globally unique, user-friendly identifier.", + "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported.", + "name": "The name of the folder.", + "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}.", + "owner_email": "Email address of the owner of the folder. This value is only considered during creation. Changing it afterwards will have no effect.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "container_id": schema.StringAttribute{ + Description: descriptions["container_id"], + Validators: []validator.String{ + validate.NoSeparator(), + }, + Required: true, + }, + "parent_container_id": schema.StringAttribute{ + Description: descriptions["parent_container_id"], + Computed: true, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + }, + }, + "labels": schema.MapAttribute{ + Description: descriptions["labels"], + ElementType: types.StringType, + Computed: 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"), + ), + }, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *folderDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + containerId := model.ContainerId.ValueString() + ctx = tflog.SetField(ctx, "container_id", containerId) + + folderResp, err := d.client.GetFolderDetails(ctx, containerId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading folder", + fmt.Sprintf("folder with ID %q does not exist.", containerId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("folder with ID %q not found or forbidden access", containerId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + err = mapFolderDetailsFields(ctx, folderResp, &model, &resp.State) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading folder", fmt.Sprintf("Processing API response: %v", err)) + return + } + + diags = resp.State.Set(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Resource Manager folder read") +} diff --git a/stackit/internal/services/resourcemanager/folder/resource.go b/stackit/internal/services/resourcemanager/folder/resource.go new file mode 100644 index 000000000..c5ad200d2 --- /dev/null +++ b/stackit/internal/services/resourcemanager/folder/resource.go @@ -0,0 +1,456 @@ +package folder + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "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/path" + "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/tfsdk" + "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" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + resourcemanagerUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &folderResource{} + _ resource.ResourceWithConfigure = &folderResource{} + _ resource.ResourceWithImportState = &folderResource{} +) + +const ( + projectOwnerRole = "owner" +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ContainerId types.String `tfsdk:"container_id"` + ContainerParentId types.String `tfsdk:"parent_container_id"` + Name types.String `tfsdk:"name"` + Labels types.Map `tfsdk:"labels"` +} + +type ResourceModel struct { + Model + OwnerEmail types.String `tfsdk:"owner_email"` +} + +// NewFolderResource is a helper function to simplify the provider implementation. +func NewFolderResource() resource.Resource { + return &folderResource{} +} + +// folderResource is the resource implementation. +type folderResource struct { + client *resourcemanager.APIClient +} + +// Metadata returns the resource type name. +func (r *folderResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_resourcemanager_folder" +} + +// Configure adds the provider configured client to the resource. +func (r *folderResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_resourcemanager_folder", "resource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := resourcemanagerUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Resource Manager client configured") +} + +// Schema defines the schema for the resource. +func (r *folderResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Resource Manager folder resource schema.", + "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", + "container_id": "Folder container ID. Globally unique, user-friendly identifier.", + "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported.", + "name": "The name of the folder.", + "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}.", + "owner_email": "Email address of the owner of the folder. This value is only considered during creation. Changing it afterwards will have no effect.", + } + + resp.Schema = schema.Schema{ + Description: features.AddBetaDescription(descriptions["main"], core.Resource), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "container_id": schema.StringAttribute{ + Description: descriptions["container_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "parent_container_id": schema.StringAttribute{ + Description: descriptions["parent_container_id"], + Required: true, + Validators: []validator.String{ + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + }, + }, + "labels": schema.MapAttribute{ + Description: descriptions["labels"], + 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"), + ), + }, + }, + "owner_email": schema.StringAttribute{ + Description: descriptions["owner_email"], + Required: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *folderResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + tflog.Info(ctx, "creating folder") + var model ResourceModel + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + containerParentId := model.ContainerParentId.ValueString() + folderName := model.Name.ValueString() + ctx = tflog.SetField(ctx, "container_parent_id", containerParentId) + ctx = tflog.SetField(ctx, "folder_name", folderName) + + // Generate API request body from model + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating folder", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + folderResp, err := r.client.CreateFolder(ctx).CreateFolderPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating folder", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFolderCreateFields(ctx, folderResp, &model.Model, &resp.State) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "API response processing error", err.Error()) + return + } + + // This sleep is currently needed due to the IAM Cache. + time.Sleep(10 * time.Second) + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + tflog.Info(ctx, "Folder created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *folderResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model ResourceModel + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + containerId := model.ContainerId.ValueString() + folderName := model.Name.ValueString() + ctx = tflog.SetField(ctx, "folder_name", folderName) + ctx = tflog.SetField(ctx, "container_id", containerId) + + folderResp, err := r.client.GetFolderDetails(ctx, containerId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusForbidden { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading folder", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFolderDetailsFields(ctx, folderResp, &model.Model, &resp.State) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading folder", fmt.Sprintf("Processing API response: %v", err)) + return + } + + // Set refreshed model + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Resource Manager folder read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *folderResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model ResourceModel + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + containerId := model.ContainerId.ValueString() + ctx = tflog.SetField(ctx, "container_id", containerId) + + // Generate API request body from model + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating folder", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing folder + _, err = r.client.PartialUpdateFolder(ctx, containerId).PartialUpdateFolderPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating folder", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Fetch updated folder + folderResp, err := r.client.GetFolderDetails(ctx, containerId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating folder", fmt.Sprintf("Calling API for updated data: %v", err)) + return + } + + err = mapFolderDetailsFields(ctx, folderResp, &model.Model, &resp.State) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating folder", fmt.Sprintf("Processing API response: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Resource Manager folder updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *folderResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model ResourceModel + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + containerId := model.ContainerId.ValueString() + ctx = tflog.SetField(ctx, "container_id", containerId) + + // Delete existing folder + err := r.client.DeleteFolder(ctx, containerId).Execute() + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error deleting folder. Deletion may fail because associated projects remain hidden for up to 7 days after user deletion due to technical requirements.", + fmt.Sprintf("API call failed: %v", err), + ) + return + } + + tflog.Info(ctx, "Resource Manager folder deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: container_id +func (r *folderResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 1 || idParts[0] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing folder", + fmt.Sprintf("Expected import identifier with format: [container_id] Got: %q", req.ID), + ) + return + } + + ctx = tflog.SetField(ctx, "container_id", req.ID) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("container_id"), req.ID)...) + tflog.Info(ctx, "Resource Manager folder state imported") +} + +// mapFolderFields maps folder fields from a response into the Terraform model and optionally updates state. +func mapFolderFields(ctx context.Context, containerId, name *string, labels *map[string]string, containerParent *resourcemanager.Parent, model *Model, state *tfsdk.State) error { //nolint:gocritic + if containerId == nil || *containerId == "" { + return fmt.Errorf("container id is present") + } + + var err error + var tfLabels basetypes.MapValue + if labels != nil && len(*labels) > 0 { + tfLabels, err = conversion.ToTerraformStringMap(ctx, *labels) + if err != nil { + return fmt.Errorf("converting to StringValue map: %w", err) + } + } else { + tfLabels = types.MapNull(types.StringType) + } + + var containerParentIdTF basetypes.StringValue + if containerParent != nil { + if _, err := uuid.Parse(model.ContainerParentId.ValueString()); err == nil { + // the provided containerParent is the UUID identifier + containerParentIdTF = types.StringPointerValue(containerParent.Id) + } else { + // the provided containerParent is the user-friendly container id + containerParentIdTF = types.StringPointerValue(containerParent.ContainerId) + } + } else { + containerParentIdTF = types.StringNull() + } + + model.Id = types.StringValue(*containerId) + model.ContainerId = types.StringValue(*containerId) + model.ContainerParentId = containerParentIdTF + model.Name = types.StringPointerValue(name) + model.Labels = tfLabels + + if state != nil { + diags := diag.Diagnostics{} + diags.Append(state.SetAttribute(ctx, path.Root("id"), model.Id)...) + diags.Append(state.SetAttribute(ctx, path.Root("parent_container_id"), model.ContainerParentId)...) + diags.Append(state.SetAttribute(ctx, path.Root("container_id"), model.ContainerId)...) + diags.Append(state.SetAttribute(ctx, path.Root("name"), model.Name)...) + diags.Append(state.SetAttribute(ctx, path.Root("labels"), model.Labels)...) + if diags.HasError() { + return fmt.Errorf("update terraform state: %w", core.DiagsToError(diags)) + } + } + + return nil +} + +// mapFolderCreateFields maps the Create Folder API response to the Terraform model and update the Terraform state +func mapFolderCreateFields(ctx context.Context, resp *resourcemanager.FolderResponse, model *Model, state *tfsdk.State) error { + return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, model, state) +} + +// mapFolderDetailsFields maps the GetDetails API response to the Terraform model and update the Terraform state +func mapFolderDetailsFields(ctx context.Context, resp *resourcemanager.GetFolderDetailsResponse, model *Model, state *tfsdk.State) error { + return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, model, state) +} + +func toMembersPayload(model *ResourceModel) (*[]resourcemanager.Member, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + if model.OwnerEmail.IsNull() { + return nil, fmt.Errorf("owner_email is null") + } + + return &[]resourcemanager.Member{ + { + Subject: model.OwnerEmail.ValueStringPointer(), + Role: sdkUtils.Ptr(projectOwnerRole), + }, + }, nil +} + +func toCreatePayload(model *ResourceModel) (*resourcemanager.CreateFolderPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + members, err := toMembersPayload(model) + if err != nil { + return nil, fmt.Errorf("processing members: %w", err) + } + + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &resourcemanager.CreateFolderPayload{ + ContainerParentId: conversion.StringValueToPointer(model.ContainerParentId), + Labels: labels, + Members: members, + Name: conversion.StringValueToPointer(model.Name), + }, nil +} + +func toUpdatePayload(model *ResourceModel) (*resourcemanager.PartialUpdateFolderPayload, error) { + if model == nil { + 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) + } + + return &resourcemanager.PartialUpdateFolderPayload{ + ContainerParentId: conversion.StringValueToPointer(model.ContainerParentId), + Name: conversion.StringValueToPointer(model.Name), + Labels: labels, + }, nil +} diff --git a/stackit/internal/services/resourcemanager/folder/resource_test.go b/stackit/internal/services/resourcemanager/folder/resource_test.go new file mode 100644 index 000000000..e059bd914 --- /dev/null +++ b/stackit/internal/services/resourcemanager/folder/resource_test.go @@ -0,0 +1,486 @@ +package folder + +import ( + "context" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" +) + +func TestMapFolderFields(t *testing.T) { + testUUID := "73b2d741-bddd-471f-8d47-3d1aa677a19c" + + tests := []struct { + description string + uuidContainerParentId bool + respContainerId *string + respName *string + labels *map[string]string + parent *resourcemanager.Parent + expected Model + expectedLabels *map[string]string + isValid bool + }{ + { + "valid input with UUID parent ID", + true, + utils.Ptr("folder-cid-uuid"), + utils.Ptr("folder-name"), + &map[string]string{ + "env": "prod", + }, + &resourcemanager.Parent{ + Id: utils.Ptr(testUUID), + }, + Model{ + Id: types.StringValue("folder-cid-uuid"), + ContainerId: types.StringValue("folder-cid-uuid"), + ContainerParentId: types.StringValue(testUUID), + Name: types.StringValue("folder-name"), + }, + &map[string]string{ + "env": "prod", + }, + true, + }, + { + "valid input with UUID parent ID no labels", + true, + utils.Ptr("folder-cid-uuid"), + utils.Ptr("folder-name"), + nil, + &resourcemanager.Parent{ + Id: utils.Ptr(testUUID), + }, + Model{ + Id: types.StringValue("folder-cid-uuid"), + ContainerId: types.StringValue("folder-cid-uuid"), + ContainerParentId: types.StringValue(testUUID), + Name: types.StringValue("folder-name"), + }, + nil, + true, + }, + { + "valid input with ContainerId as parent", + false, + utils.Ptr("folder-cid"), + utils.Ptr("folder-name"), + &map[string]string{ + "env": "dev", + }, + &resourcemanager.Parent{ + ContainerId: utils.Ptr("parent-container-id"), + }, + Model{ + Id: types.StringValue("folder-cid"), + ContainerId: types.StringValue("folder-cid"), + ContainerParentId: types.StringValue("parent-container-id"), + Name: types.StringValue("folder-name"), + }, + &map[string]string{ + "env": "dev", + }, + true, + }, + { + "valid input with ContainerId as parent no labels", + false, + utils.Ptr("folder-cid"), + utils.Ptr("folder-name"), + nil, + &resourcemanager.Parent{ + ContainerId: utils.Ptr("parent-container-id"), + }, + Model{ + Id: types.StringValue("folder-cid"), + ContainerId: types.StringValue("folder-cid"), + ContainerParentId: types.StringValue("parent-container-id"), + Name: types.StringValue("folder-name"), + }, + nil, + true, + }, + { + "nil labels", + false, + utils.Ptr("folder-cid"), + utils.Ptr("folder-name"), + nil, + nil, + Model{ + Id: types.StringValue("folder-cid"), + ContainerId: types.StringValue("folder-cid"), + ContainerParentId: types.StringNull(), + Name: types.StringValue("folder-name"), + }, + nil, + true, + }, + { + "nil container ID, should fail", + false, + nil, + utils.Ptr("name"), + nil, + nil, + Model{}, + nil, + false, + }, + { + "empty container ID, should fail", + false, + utils.Ptr(""), + utils.Ptr("name"), + nil, + nil, + Model{}, + nil, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + // Handle expected label conversion + if tt.expectedLabels == nil { + tt.expected.Labels = types.MapNull(types.StringType) + } else { + convertedLabels, err := conversion.ToTerraformStringMap(context.Background(), *tt.expectedLabels) + if err != nil { + t.Fatalf("Error converting to terraform string map: %v", err) + } + tt.expected.Labels = convertedLabels + } + + // Simulate ContainerParentId configuration based on UUID detection logic + var containerParentId basetypes.StringValue + if tt.uuidContainerParentId { + containerParentId = types.StringValue(testUUID) + } else if tt.parent != nil && tt.parent.ContainerId != nil { + containerParentId = types.StringValue(*tt.parent.ContainerId) + } else { + containerParentId = types.StringNull() + } + + model := &Model{ + ContainerId: tt.expected.ContainerId, + ContainerParentId: containerParentId, + } + + err := mapFolderFields(context.Background(), tt.respContainerId, tt.respName, tt.labels, tt.parent, model, nil) + + 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(model, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapFolderCreateFields(t *testing.T) { + labels := map[string]string{ + "env": "prod", + } + resp := &resourcemanager.FolderResponse{ + ContainerId: utils.Ptr("folder-id"), + Name: utils.Ptr("my-folder"), + Labels: &labels, + Parent: &resourcemanager.Parent{ + Id: utils.Ptr(uuid.New().String()), + }, + } + + model := Model{ + ContainerParentId: types.StringValue(*resp.Parent.Id), + } + + err := mapFolderCreateFields(context.Background(), resp, &model, nil) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + cbLabels, _ := conversion.ToTerraformStringMap(context.Background(), labels) + expected := Model{ + Id: types.StringValue("folder-id"), + ContainerId: types.StringValue("folder-id"), + ContainerParentId: types.StringValue(*resp.Parent.Id), + Name: types.StringValue("my-folder"), + Labels: cbLabels, + } + diff := cmp.Diff(model, expected) + if diff != "" { + t.Fatalf("mapFolderCreateFields() mismatch: %s", diff) + } +} + +func TestMapFolderDetailsFields(t *testing.T) { + resp := &resourcemanager.GetFolderDetailsResponse{ + ContainerId: utils.Ptr("folder-id"), + Name: utils.Ptr("details-folder"), + Labels: &map[string]string{ + "foo": "bar", + }, + Parent: &resourcemanager.Parent{ + ContainerId: utils.Ptr("parent-container"), + }, + } + + var model Model + err := mapFolderDetailsFields(context.Background(), resp, &model, nil) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + tfLabels, _ := conversion.ToTerraformStringMap(context.Background(), *resp.Labels) + + expected := Model{ + Id: types.StringValue("folder-id"), + ContainerId: types.StringValue("folder-id"), + ContainerParentId: types.StringValue("parent-container"), + Name: types.StringValue("details-folder"), + Labels: tfLabels, + } + + diff := cmp.Diff(model, expected) + if diff != "" { + t.Fatalf("mapFolderDetailsFields() mismatch: %s", diff) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *ResourceModel + inputLabels *map[string]string + expected *resourcemanager.CreateFolderPayload + isValid bool + }{ + { + "mapping_with_conversions", + &ResourceModel{ + Model: Model{ + ContainerParentId: types.StringValue("pid"), + Name: types.StringValue("name"), + }, + OwnerEmail: types.StringValue("john.doe@stackit.cloud"), + }, + &map[string]string{ + "label1": "1", + "label2": "2", + }, + &resourcemanager.CreateFolderPayload{ + ContainerParentId: utils.Ptr("pid"), + Labels: &map[string]string{ + "label1": "1", + "label2": "2", + }, + Members: &[]resourcemanager.Member{ + { + Subject: utils.Ptr("john.doe@stackit.cloud"), + Role: utils.Ptr("owner"), + }, + }, + Name: utils.Ptr("name"), + }, + true, + }, + { + "no owner_email fails", + &ResourceModel{ + Model: Model{ + ContainerParentId: types.StringValue("pid"), + Name: types.StringValue("name"), + }, + }, + &map[string]string{}, + nil, + false, + }, + { + "nil_model", + nil, + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if tt.input != nil { + if tt.inputLabels == nil { + tt.input.Labels = types.MapNull(types.StringType) + } else { + convertedLabels, err := conversion.ToTerraformStringMap(context.Background(), *tt.inputLabels) + if err != nil { + t.Fatalf("Error converting to terraform string map: %v", err) + } + tt.input.Labels = convertedLabels + } + } + output, err := toCreatePayload(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.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *ResourceModel + inputLabels *map[string]string + expected *resourcemanager.PartialUpdateFolderPayload + isValid bool + }{ + { + "default_ok", + &ResourceModel{}, + nil, + &resourcemanager.PartialUpdateFolderPayload{ + ContainerParentId: nil, + Labels: nil, + Name: nil, + }, + true, + }, + { + "mapping_with_conversions_ok", + &ResourceModel{ + Model: Model{ + ContainerParentId: types.StringValue("pid"), + Name: types.StringValue("name"), + }, + OwnerEmail: types.StringValue("owner_email"), + }, + &map[string]string{ + "label1": "1", + "label2": "2", + }, + &resourcemanager.PartialUpdateFolderPayload{ + ContainerParentId: utils.Ptr("pid"), + Labels: &map[string]string{ + "label1": "1", + "label2": "2", + }, + Name: utils.Ptr("name"), + }, + true, + }, + { + "nil_model", + nil, + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if tt.input != nil { + if tt.inputLabels == nil { + tt.input.Labels = types.MapNull(types.StringType) + } else { + convertedLabels, err := conversion.ToTerraformStringMap(context.Background(), *tt.inputLabels) + if err != nil { + t.Fatalf("Error converting to terraform string map: %v", err) + } + tt.input.Labels = convertedLabels + } + } + output, err := toUpdatePayload(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.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToMembersPayload(t *testing.T) { + type args struct { + model *ResourceModel + } + tests := []struct { + name string + args args + want *[]resourcemanager.Member + wantErr bool + }{ + { + name: "missing model", + args: args{}, + want: nil, + wantErr: true, + }, + { + name: "empty model", + args: args{ + model: &ResourceModel{}, + }, + want: nil, + wantErr: true, + }, + { + name: "ok", + args: args{ + model: &ResourceModel{ + OwnerEmail: types.StringValue("john.doe@stackit.cloud"), + }, + }, + want: &[]resourcemanager.Member{ + { + Subject: utils.Ptr("john.doe@stackit.cloud"), + Role: utils.Ptr("owner"), + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toMembersPayload(tt.args.model) + if (err != nil) != tt.wantErr { + t.Errorf("toMembersPayload() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("toMembersPayload() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go index 7fb666d22..a9881da87 100644 --- a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go +++ b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go @@ -2,176 +2,420 @@ package resourcemanager_test import ( "context" + _ "embed" + "errors" "fmt" + "maps" + "sync" "testing" + "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" - "github.com/stackitcloud/stackit-sdk-go/core/config" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) -// Project resource data -var projectResource = map[string]string{ - "name": fmt.Sprintf("acc-pj-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)), - "parent_container_id": testutil.TestProjectParentContainerID, - "parent_uuid": testutil.TestProjectParentUUID, - "billing_reference": "TEST-REF", - "new_label": "a-label", -} - -func resourceConfig(name string, label *string) string { - labelConfig := "" - if label != nil { - labelConfig = fmt.Sprintf("new_label = %q", *label) - } - return fmt.Sprintf(` - %[1]s - - resource "stackit_resourcemanager_project" "parent_by_container" { - parent_container_id = "%[2]s" - name = "%[3]s" - labels = { - "billing_reference" = "%[4]s" - %[5]s - } - owner_email = "%[7]s" - } - - resource "stackit_resourcemanager_project" "parent_by_uuid" { - parent_container_id = "%[6]s" - name = "%[3]s-uuid" - owner_email = "%[7]s" - } - `, - testutil.ResourceManagerProviderConfig(), - projectResource["parent_container_id"], - name, - projectResource["billing_reference"], - labelConfig, - projectResource["parent_uuid"], - testutil.TestProjectServiceAccountEmail, - ) -} - -func TestAccResourceManagerResource(t *testing.T) { +//go:embed testdata/resource-project.tf +var resourceProject string + +//go:embed testdata/resource-folder.tf +var resourceFolder string + +var defaultLabels = config.ObjectVariable( + map[string]config.Variable{ + "env": config.StringVariable("prod"), + }, +) + +var projectNameParentContainerId = fmt.Sprintf("tfe2e-project-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) +var projectNameParentContainerIdUpdated = fmt.Sprintf("%s-updated", projectNameParentContainerId) + +var projectNameParentUUID = fmt.Sprintf("tfe2e-project-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) +var projectNameParentUUIDUpdated = fmt.Sprintf("%s-updated", projectNameParentUUID) + +var folderNameParentContainerId = fmt.Sprintf("tfe2e-folder-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) +var folderNameParentContainerIdUpdated = fmt.Sprintf("%s-updated", folderNameParentContainerId) + +var folderNameParentUUID = fmt.Sprintf("tfe2e-folder-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) +var folderNameParentUUIDUpdated = fmt.Sprintf("%s-updated", folderNameParentUUID) + +var testConfigResourceProjectParentContainerId = config.Variables{ + "name": config.StringVariable(projectNameParentContainerId), + "owner_email": config.StringVariable(testutil.TestProjectServiceAccountEmail), + "parent_container_id": config.StringVariable(testutil.TestProjectParentContainerID), + "labels": config.ObjectVariable( + map[string]config.Variable{ + "env": config.StringVariable("prod"), + }, + ), +} + +var testConfigResourceProjectParentUUID = config.Variables{ + "name": config.StringVariable(projectNameParentUUID), + "owner_email": config.StringVariable(testutil.TestProjectServiceAccountEmail), + "parent_container_id": config.StringVariable(testutil.TestProjectParentUUID), + "labels": defaultLabels, +} + +var testConfigResourceFolderParentContainerId = config.Variables{ + "name": config.StringVariable(folderNameParentContainerId), + "owner_email": config.StringVariable(testutil.TestProjectServiceAccountEmail), + "parent_container_id": config.StringVariable(testutil.TestProjectParentContainerID), + "labels": defaultLabels, +} + +var testConfigResourceFolderParentUUID = config.Variables{ + "name": config.StringVariable(folderNameParentUUID), + "owner_email": config.StringVariable(testutil.TestProjectServiceAccountEmail), + "parent_container_id": config.StringVariable(testutil.TestProjectParentUUID), + "labels": defaultLabels, +} + +func testConfigProjectNameParentContainerIdUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigResourceProjectParentContainerId)) + maps.Copy(tempConfig, testConfigResourceProjectParentContainerId) + tempConfig["name"] = config.StringVariable(projectNameParentContainerIdUpdated) + return tempConfig +} + +func testConfigProjectNameParentUUIDUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigResourceProjectParentUUID)) + maps.Copy(tempConfig, testConfigResourceProjectParentUUID) + tempConfig["name"] = config.StringVariable(projectNameParentUUIDUpdated) + return tempConfig +} + +func testConfigFolderNameParentContainerIdUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigResourceFolderParentContainerId)) + maps.Copy(tempConfig, testConfigResourceFolderParentContainerId) + tempConfig["name"] = config.StringVariable(folderNameParentContainerIdUpdated) + return tempConfig +} + +func testConfigFolderNameParentUUIDUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigResourceFolderParentUUID)) + maps.Copy(tempConfig, testConfigResourceFolderParentUUID) + tempConfig["name"] = config.StringVariable(folderNameParentUUIDUpdated) + return tempConfig +} + +func TestAccResourceManagerProjectContainerId(t *testing.T) { + t.Logf("TestAccResourceManagerProjectContainerId name: %s", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["name"])) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckResourceManagerDestroy, + CheckDestroy: testAccCheckDestroy, Steps: []resource.TestStep{ - // Creation + // Create { - Config: resourceConfig(projectResource["name"], nil), + ConfigVariables: testConfigResourceProjectParentContainerId, + Config: testutil.ResourceManagerProviderConfig() + resourceProject, Check: resource.ComposeAggregateTestCheckFunc( - // Parent container id project data - resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_container", "container_id"), - resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_container", "project_id"), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "name", projectResource["name"]), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "parent_container_id", projectResource["parent_container_id"]), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "labels.%", "1"), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "labels.billing_reference", projectResource["billing_reference"]), - - // Parent UUID project data - resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_uuid", "container_id"), - resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_uuid", "project_id"), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_uuid", "name", fmt.Sprintf("%s-uuid", projectResource["name"])), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_uuid", "parent_container_id", projectResource["parent_uuid"]), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "owner_email", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), ), }, - // Data source + // Data Source { + ConfigVariables: testConfigResourceProjectParentContainerId, Config: fmt.Sprintf(` - %s + %s + %s - data "stackit_resourcemanager_project" "project_by_container" { - container_id = stackit_resourcemanager_project.parent_by_container.container_id - } - - data "stackit_resourcemanager_project" "project_by_uuid" { - project_id = stackit_resourcemanager_project.parent_by_container.project_id - } + data "stackit_resourcemanager_project" "example" { + project_id = stackit_resourcemanager_project.example.project_id + } + `, testutil.ResourceManagerProviderConfig(), resourceProject), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["name"])), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "parent_container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "id"), + ), + }, + // Import + { + ConfigVariables: testConfigResourceProjectParentContainerId, + ResourceName: "stackit_resourcemanager_project.example", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return getImportIdFromID(s, "stackit_resourcemanager_project.example", "container_id") + }, + ImportStateVerifyIgnore: []string{"owner_email"}, + }, + // Update + { + ConfigVariables: testConfigProjectNameParentContainerIdUpdated(), + Config: testutil.ResourceManagerProviderConfig() + resourceProject, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigProjectNameParentContainerIdUpdated()["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "owner_email", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), + ), + }, + }, + }) +} - data "stackit_resourcemanager_project" "project_by_both" { - container_id = stackit_resourcemanager_project.parent_by_container.container_id - project_id = stackit_resourcemanager_project.parent_by_container.project_id - } - `, - resourceConfig(projectResource["name"], nil), +func TestAccResourceManagerProjectParentUUID(t *testing.T) { + t.Logf("TestAccResourceManagerProjectParentUUID name: %s", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["name"])) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, + Steps: []resource.TestStep{ + // Create + { + ConfigVariables: testConfigResourceProjectParentUUID, + Config: testutil.ResourceManagerProviderConfig() + resourceProject, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "owner_email", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), ), + }, + // Data Source + { + ConfigVariables: testConfigResourceProjectParentUUID, + Config: fmt.Sprintf(` + %s + %s + + data "stackit_resourcemanager_project" "example" { + project_id = stackit_resourcemanager_project.example.project_id + } + `, testutil.ResourceManagerProviderConfig(), resourceProject), Check: resource.ComposeAggregateTestCheckFunc( - // Container project data - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_container", "id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_container", "container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_container", "project_id"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_container", "name", projectResource["name"]), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_container", "parent_container_id"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_container", "labels.%", "1"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_container", "labels.billing_reference", projectResource["billing_reference"]), - - // UUID project data - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_uuid", "id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_uuid", "container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_uuid", "project_id"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_uuid", "name", projectResource["name"]), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_uuid", "parent_container_id"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_uuid", "labels.%", "1"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_uuid", "labels.billing_reference", projectResource["billing_reference"]), - - // Both project data - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_both", "id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_both", "container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_both", "project_id"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_both", "name", projectResource["name"]), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.project_by_both", "parent_container_id"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_both", "labels.%", "1"), - resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.project_by_both", "labels.billing_reference", projectResource["billing_reference"]), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["name"])), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "parent_container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "id"), ), }, // Import { - ResourceName: "stackit_resourcemanager_project.parent_by_container", + ConfigVariables: testConfigResourceProjectParentUUID, + ResourceName: "stackit_resourcemanager_project.example", + ImportState: true, + ImportStateVerify: true, ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_resourcemanager_project.parent_by_container"] - if !ok { - return "", fmt.Errorf("couldn't find resource stackit_resourcemanager_project.parent_by_container") - } - containerId, ok := r.Primary.Attributes["container_id"] - if !ok { - return "", fmt.Errorf("couldn't find attribute container_id") - } - - return containerId, nil + return getImportIdFromID(s, "stackit_resourcemanager_project.example", "container_id") }, + ImportStateVerifyIgnore: []string{"owner_email", "parent_container_id"}, + }, + // Update + { + ConfigVariables: testConfigProjectNameParentUUIDUpdated(), + Config: testutil.ResourceManagerProviderConfig() + resourceProject, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigProjectNameParentUUIDUpdated()["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "owner_email", testutil.ConvertConfigVariable(testConfigResourceProjectParentUUID["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_project.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), + ), + }, + }, + }) +} + +func TestAccResourceManagerFolderContainerId(t *testing.T) { + t.Logf("TestAccResourceManagerFolderContainerId name: %s", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["name"])) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, + Steps: []resource.TestStep{ + // Create + { + ConfigVariables: testConfigResourceFolderParentContainerId, + Config: testutil.ResourceManagerProviderConfigBetaEnabled() + resourceFolder, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "owner_email", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), + ), + }, + // Data Source + { + ConfigVariables: testConfigResourceFolderParentContainerId, + Config: fmt.Sprintf(` + %s + %s + + data "stackit_resourcemanager_folder" "example" { + container_id = stackit_resourcemanager_folder.example.container_id + } + `, testutil.ResourceManagerProviderConfigBetaEnabled(), resourceFolder), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["name"])), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), + ), + }, + // Import + { + ConfigVariables: testConfigResourceFolderParentContainerId, + ResourceName: "stackit_resourcemanager_folder.example", ImportState: true, ImportStateVerify: true, - // The owner_email attributes don't exist in the - // API, therefore there is no value for it during import. + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return getImportIdFromID(s, "stackit_resourcemanager_folder.example", "container_id") + }, ImportStateVerifyIgnore: []string{"owner_email"}, }, // Update { - Config: resourceConfig(fmt.Sprintf("%s-new", projectResource["name"]), utils.Ptr("a-label")), + ConfigVariables: testConfigFolderNameParentContainerIdUpdated(), + Config: testutil.ResourceManagerProviderConfigBetaEnabled() + resourceFolder, Check: resource.ComposeAggregateTestCheckFunc( - // Project data - resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_container", "container_id"), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "name", fmt.Sprintf("%s-new", projectResource["name"])), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "parent_container_id", projectResource["parent_container_id"]), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "labels.%", "2"), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "labels.billing_reference", projectResource["billing_reference"]), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "labels.new_label", projectResource["new_label"]), - resource.TestCheckResourceAttr("stackit_resourcemanager_project.parent_by_container", "owner_email", testutil.TestProjectServiceAccountEmail), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigFolderNameParentContainerIdUpdated()["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigFolderNameParentContainerIdUpdated()["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "owner_email", testutil.ConvertConfigVariable(testConfigFolderNameParentContainerIdUpdated()["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "owner_email"), ), }, - // Deletion is done by the framework implicitly }, }) } -func testAccCheckResourceManagerDestroy(s *terraform.State) error { +func TestAccResourceManagerFolderParentUUID(t *testing.T) { + t.Logf("TestAccResourceManagerFolderParentUUID name: %s", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["name"])) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, + Steps: []resource.TestStep{ + // Create + { + ConfigVariables: testConfigResourceFolderParentUUID, + Config: testutil.ResourceManagerProviderConfigBetaEnabled() + resourceFolder, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigResourceFolderParentUUID["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceFolderParentUUID["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "owner_email", testutil.ConvertConfigVariable(testConfigResourceFolderParentUUID["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), + ), + }, + // Data Source + { + ConfigVariables: testConfigResourceFolderParentUUID, + Config: fmt.Sprintf(` + %s + %s + + data "stackit_resourcemanager_folder" "example" { + container_id = stackit_resourcemanager_folder.example.container_id + } + `, testutil.ResourceManagerProviderConfigBetaEnabled(), resourceFolder), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigResourceFolderParentUUID["name"])), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.%", "1"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), + ), + }, + // Import + { + ConfigVariables: testConfigResourceFolderParentUUID, + ResourceName: "stackit_resourcemanager_folder.example", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return getImportIdFromID(s, "stackit_resourcemanager_folder.example", "container_id") + }, + ImportStateVerifyIgnore: []string{"owner_email", "parent_container_id"}, + }, + // Update + { + ConfigVariables: testConfigFolderNameParentUUIDUpdated(), + Config: testutil.ResourceManagerProviderConfigBetaEnabled() + resourceFolder, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigFolderNameParentUUIDUpdated()["name"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigFolderNameParentUUIDUpdated()["parent_container_id"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "owner_email", testutil.ConvertConfigVariable(testConfigFolderNameParentUUIDUpdated()["owner_email"])), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), + resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "owner_email"), + ), + }, + }, + }) +} + +func testAccCheckDestroy(s *terraform.State) error { + checkFunctions := []func(s *terraform.State) error{ + testAccCheckResourceManagerProjectsDestroy, + testAccCheckResourceManagerFoldersDestroy, + } + var errs []error + + wg := sync.WaitGroup{} + wg.Add(len(checkFunctions)) + + for _, f := range checkFunctions { + go func() { + err := f(s) + if err != nil { + errs = append(errs, err) + } + wg.Done() + }() + } + wg.Wait() + return errors.Join(errs...) +} + +func testAccCheckResourceManagerProjectsDestroy(s *terraform.State) error { ctx := context.Background() var client *resourcemanager.APIClient var err error @@ -179,7 +423,7 @@ func testAccCheckResourceManagerDestroy(s *terraform.State) error { client, err = resourcemanager.NewAPIClient() } else { client, err = resourcemanager.NewAPIClient( - config.WithEndpoint(testutil.ResourceManagerCustomEndpoint), + sdkConfig.WithEndpoint(testutil.ResourceManagerCustomEndpoint), ) } if err != nil { @@ -196,7 +440,17 @@ func testAccCheckResourceManagerDestroy(s *terraform.State) error { projectsToDestroy = append(projectsToDestroy, containerId) } - projectsResp, err := client.ListProjects(ctx).ContainerParentId(projectResource["parent_container_id"]).Execute() + var containerParentId string + switch { + case testutil.TestProjectParentContainerID != "": + containerParentId = testutil.TestProjectParentContainerID + case testutil.TestProjectParentUUID != "": + containerParentId = testutil.TestProjectParentUUID + default: + return fmt.Errorf("either TestProjectParentContainerID or TestProjectParentUUID must be set") + } + + projectsResp, err := client.ListProjects(ctx).ContainerParentId(containerParentId).Execute() if err != nil { return fmt.Errorf("getting projectsResp: %w", err) } @@ -221,3 +475,69 @@ func testAccCheckResourceManagerDestroy(s *terraform.State) error { } return nil } + +func testAccCheckResourceManagerFoldersDestroy(s *terraform.State) error { + ctx := context.Background() + var client *resourcemanager.APIClient + var err error + if testutil.ResourceManagerCustomEndpoint == "" { + client, err = resourcemanager.NewAPIClient() + } else { + client, err = resourcemanager.NewAPIClient( + sdkConfig.WithEndpoint(testutil.ResourceManagerCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + foldersToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_resourcemanager_folder" { + continue + } + // project terraform ID: "[container_id]" + containerId := rs.Primary.ID + foldersToDestroy = append(foldersToDestroy, containerId) + } + + var containerParentId string + switch { + case testutil.TestProjectParentContainerID != "": + containerParentId = testutil.TestProjectParentContainerID + case testutil.TestProjectParentUUID != "": + containerParentId = testutil.TestProjectParentUUID + default: + return fmt.Errorf("either TestProjectParentContainerID or TestProjectParentUUID must be set") + } + + projectsResp, err := client.ListFolders(ctx).ContainerParentId(containerParentId).Execute() + if err != nil { + return fmt.Errorf("getting projectsResp: %w", err) + } + + items := *projectsResp.Items + for i := range items { + if !utils.Contains(foldersToDestroy, *items[i].ContainerId) { + continue + } + + err := client.DeleteFolder(ctx, *items[i].ContainerId).Execute() + if err != nil { + return fmt.Errorf("destroying folder %s during CheckDestroy: %w", *items[i].ContainerId, err) + } + } + return nil +} + +func getImportIdFromID(s *terraform.State, resourceName, keyName string) (string, error) { + r, ok := s.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("couldn't find resource %s", resourceName) + } + id, ok := r.Primary.Attributes[keyName] + if !ok { + return "", fmt.Errorf("couldn't find attribute %s", keyName) + } + return id, nil +} diff --git a/stackit/internal/services/resourcemanager/testdata/resource-folder.tf b/stackit/internal/services/resourcemanager/testdata/resource-folder.tf new file mode 100644 index 000000000..68f9b0d0e --- /dev/null +++ b/stackit/internal/services/resourcemanager/testdata/resource-folder.tf @@ -0,0 +1,12 @@ + +variable "parent_container_id" {} +variable "name" {} +variable "labels" {} +variable "owner_email" {} + +resource "stackit_resourcemanager_folder" "example" { + parent_container_id = var.parent_container_id + name = var.name + labels = var.labels + owner_email = var.owner_email +} diff --git a/stackit/internal/services/resourcemanager/testdata/resource-project.tf b/stackit/internal/services/resourcemanager/testdata/resource-project.tf new file mode 100644 index 000000000..0de2ee190 --- /dev/null +++ b/stackit/internal/services/resourcemanager/testdata/resource-project.tf @@ -0,0 +1,12 @@ + +variable "parent_container_id" {} +variable "name" {} +variable "labels" {} +variable "owner_email" {} + +resource "stackit_resourcemanager_project" "example" { + parent_container_id = var.parent_container_id + name = var.name + labels = var.labels + owner_email = var.owner_email +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 25576f36d..4c6456dc8 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -344,6 +344,31 @@ func ResourceManagerProviderConfig() string { ) } +func ResourceManagerProviderConfigBetaEnabled() string { + token := GetTestProjectServiceAccountToken("") + if ResourceManagerCustomEndpoint == "" || AuthorizationCustomEndpoint == "" { + return fmt.Sprintf(` + provider "stackit" { + service_account_token = "%s" + enable_beta_resources = true + }`, + + token, + ) + } + return fmt.Sprintf(` + provider "stackit" { + resourcemanager_custom_endpoint = "%s" + authorization_custom_endpoint = "%s" + service_account_token = "%s" + enable_beta_resources = true + }`, + ResourceManagerCustomEndpoint, + AuthorizationCustomEndpoint, + token, + ) +} + func SecretsManagerProviderConfig() string { if SecretsManagerCustomEndpoint == "" { return ` diff --git a/stackit/provider.go b/stackit/provider.go index c3ac8f336..5a938d757 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -74,6 +74,7 @@ import ( rabbitMQInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/rabbitmq/instance" redisCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/redis/credential" redisInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/redis/instance" + resourceManagerFolder "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/folder" resourceManagerProject "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/project" secretsManagerInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/instance" secretsManagerUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/user" @@ -499,6 +500,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource redisInstance.NewInstanceDataSource, redisCredential.NewCredentialDataSource, resourceManagerProject.NewProjectDataSource, + resourceManagerFolder.NewFolderDataSource, secretsManagerInstance.NewInstanceDataSource, secretsManagerUser.NewUserDataSource, sqlServerFlexInstance.NewInstanceDataSource, @@ -565,6 +567,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { redisInstance.NewInstanceResource, redisCredential.NewCredentialResource, resourceManagerProject.NewProjectResource, + resourceManagerFolder.NewFolderResource, secretsManagerInstance.NewInstanceResource, secretsManagerUser.NewUserResource, sqlServerFlexInstance.NewInstanceResource, From 46ee052e4fbf4c1b977f76d4d4ff77f3fcd8d33f Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 2 Sep 2025 11:11:48 +0200 Subject: [PATCH 2/7] feat(resourcemanager): improve project docs Signed-off-by: Mauritz Uphoff --- docs/resources/resourcemanager_project.md | 4 ++-- stackit/internal/services/resourcemanager/project/resource.go | 2 +- stackit/internal/testutil/testutil.go | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/resources/resourcemanager_project.md b/docs/resources/resourcemanager_project.md index 9ffa5ebf5..4a79ee7f4 100644 --- a/docs/resources/resourcemanager_project.md +++ b/docs/resources/resourcemanager_project.md @@ -3,13 +3,13 @@ page_title: "stackit_resourcemanager_project Resource - stackit" subcategory: "" description: |- - Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration. + Resource Manager project resource schema. -> In case you're getting started with an empty STACKIT organization and want to use this resource to create projects in it, check out this guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/stackit_org_service_account for how to create a service account which you can use for authentication in the STACKIT Terraform provider. --- # stackit_resourcemanager_project (Resource) -Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration. +Resource Manager project resource schema. -> In case you're getting started with an empty STACKIT organization and want to use this resource to create projects in it, check out [this guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/stackit_org_service_account) for how to create a service account which you can use for authentication in the STACKIT Terraform provider. diff --git a/stackit/internal/services/resourcemanager/project/resource.go b/stackit/internal/services/resourcemanager/project/resource.go index e15733084..efe4b4368 100644 --- a/stackit/internal/services/resourcemanager/project/resource.go +++ b/stackit/internal/services/resourcemanager/project/resource.go @@ -92,7 +92,7 @@ func (r *projectResource) Configure(ctx context.Context, req resource.ConfigureR func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { descriptions := map[string]string{ "main": fmt.Sprintf("%s\n\n%s", - "Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration.", + "Resource Manager project resource schema.", "-> In case you're getting started with an empty STACKIT organization and want to use this resource to create projects in it, check out [this guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/stackit_org_service_account) for how to create a service account which you can use for authentication in the STACKIT Terraform provider.", ), "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 4c6456dc8..fdfdf52ac 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -323,10 +323,8 @@ func ResourceManagerProviderConfig() string { if ResourceManagerCustomEndpoint == "" || AuthorizationCustomEndpoint == "" { return fmt.Sprintf(` provider "stackit" { - service_account_email = "%s" service_account_token = "%s" }`, - TestProjectServiceAccountEmail, token, ) } @@ -334,12 +332,10 @@ func ResourceManagerProviderConfig() string { provider "stackit" { resourcemanager_custom_endpoint = "%s" authorization_custom_endpoint = "%s" - service_account_email = "%s" service_account_token = "%s" }`, ResourceManagerCustomEndpoint, AuthorizationCustomEndpoint, - TestProjectServiceAccountEmail, token, ) } From 8d90b0d3327034b8c6b34f576931133faf35b7e7 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 2 Sep 2025 11:35:38 +0200 Subject: [PATCH 3/7] feat(resourcemanager): add created_at and updated_at attributes to resourcemanager project/folder Signed-off-by: Mauritz Uphoff --- docs/data-sources/resourcemanager_folder.md | 2 + docs/data-sources/resourcemanager_project.md | 2 + docs/resources/resourcemanager_folder.md | 2 + docs/resources/resourcemanager_project.md | 2 + .../resourcemanager/folder/datasource.go | 10 + .../resourcemanager/folder/resource.go | 31 ++- .../resourcemanager/folder/resource_test.go | 186 ++++++++++++------ .../resourcemanager/project/datasource.go | 10 + .../resourcemanager/project/resource.go | 17 ++ .../resourcemanager/project/resource_test.go | 92 +++++---- .../resourcemanager_acc_test.go | 24 +++ 11 files changed, 276 insertions(+), 102 deletions(-) diff --git a/docs/data-sources/resourcemanager_folder.md b/docs/data-sources/resourcemanager_folder.md index ab9994d05..0d60ca14e 100644 --- a/docs/data-sources/resourcemanager_folder.md +++ b/docs/data-sources/resourcemanager_folder.md @@ -27,7 +27,9 @@ data "stackit_resourcemanager_folder" "example" { ### Read-Only +- `creation_time` (String) Date-time at which the folder was created. - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}. - `name` (String) The name of the folder. - `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported. +- `update_time` (String) Date-time at which the folder was last modified. diff --git a/docs/data-sources/resourcemanager_project.md b/docs/data-sources/resourcemanager_project.md index d8a358093..07841f22c 100644 --- a/docs/data-sources/resourcemanager_project.md +++ b/docs/data-sources/resourcemanager_project.md @@ -29,7 +29,9 @@ data "stackit_resourcemanager_project" "example" { ### Read-Only +- `creation_time` (String) Date-time at which the folder was created. - `id` (String) Terraform's internal data source. ID. It is structured as "`container_id`". - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64} - `name` (String) Project name. - `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported +- `update_time` (String) Date-time at which the folder was last modified. diff --git a/docs/resources/resourcemanager_folder.md b/docs/resources/resourcemanager_folder.md index 3c25d371f..983039233 100644 --- a/docs/resources/resourcemanager_folder.md +++ b/docs/resources/resourcemanager_folder.md @@ -39,4 +39,6 @@ resource "stackit_resourcemanager_folder" "example" { ### Read-Only - `container_id` (String) Folder container ID. Globally unique, user-friendly identifier. +- `creation_time` (String) Date-time at which the folder was created. - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". +- `update_time` (String) Date-time at which the folder was last modified. diff --git a/docs/resources/resourcemanager_project.md b/docs/resources/resourcemanager_project.md index 4a79ee7f4..a914ef895 100644 --- a/docs/resources/resourcemanager_project.md +++ b/docs/resources/resourcemanager_project.md @@ -52,5 +52,7 @@ To create a project within a STACKIT Network Area, setting the label `networkAre ### Read-Only - `container_id` (String) Project container ID. Globally unique, user-friendly identifier. +- `creation_time` (String) Date-time at which the folder was created. - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". - `project_id` (String) Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project. +- `update_time` (String) Date-time at which the folder was last modified. diff --git a/stackit/internal/services/resourcemanager/folder/datasource.go b/stackit/internal/services/resourcemanager/folder/datasource.go index b0f02fb77..f54b5b9ea 100644 --- a/stackit/internal/services/resourcemanager/folder/datasource.go +++ b/stackit/internal/services/resourcemanager/folder/datasource.go @@ -71,6 +71,8 @@ func (d *folderDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "name": "The name of the folder.", "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}.", "owner_email": "Email address of the owner of the folder. This value is only considered during creation. Changing it afterwards will have no effect.", + "creation_time": "Date-time at which the folder was created.", + "update_time": "Date-time at which the folder was last modified.", } resp.Schema = schema.Schema{ @@ -119,6 +121,14 @@ func (d *folderDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, ), }, }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + }, + "update_time": schema.StringAttribute{ + Description: descriptions["update_time"], + Computed: true, + }, }, } } diff --git a/stackit/internal/services/resourcemanager/folder/resource.go b/stackit/internal/services/resourcemanager/folder/resource.go index c5ad200d2..4824a4309 100644 --- a/stackit/internal/services/resourcemanager/folder/resource.go +++ b/stackit/internal/services/resourcemanager/folder/resource.go @@ -49,6 +49,8 @@ type Model struct { ContainerParentId types.String `tfsdk:"parent_container_id"` Name types.String `tfsdk:"name"` Labels types.Map `tfsdk:"labels"` + CreationTime types.String `tfsdk:"creation_time"` + UpdateTime types.String `tfsdk:"update_time"` } type ResourceModel struct { @@ -101,6 +103,8 @@ func (r *folderResource) Schema(_ context.Context, _ resource.SchemaRequest, res "name": "The name of the folder.", "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}.", "owner_email": "Email address of the owner of the folder. This value is only considered during creation. Changing it afterwards will have no effect.", + "creation_time": "Date-time at which the folder was created.", + "update_time": "Date-time at which the folder was last modified.", } resp.Schema = schema.Schema{ @@ -159,6 +163,14 @@ func (r *folderResource) Schema(_ context.Context, _ resource.SchemaRequest, res Description: descriptions["owner_email"], Required: true, }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + }, + "update_time": schema.StringAttribute{ + Description: descriptions["update_time"], + Computed: true, + }, }, } } @@ -337,7 +349,16 @@ func (r *folderResource) ImportState(ctx context.Context, req resource.ImportSta } // mapFolderFields maps folder fields from a response into the Terraform model and optionally updates state. -func mapFolderFields(ctx context.Context, containerId, name *string, labels *map[string]string, containerParent *resourcemanager.Parent, model *Model, state *tfsdk.State) error { //nolint:gocritic +func mapFolderFields( + ctx context.Context, + containerId, name *string, + labels *map[string]string, //nolint:gocritic + containerParent *resourcemanager.Parent, + creationTime *time.Time, + updateTime *time.Time, + model *Model, + state *tfsdk.State, +) error { if containerId == nil || *containerId == "" { return fmt.Errorf("container id is present") } @@ -371,6 +392,8 @@ func mapFolderFields(ctx context.Context, containerId, name *string, labels *map model.ContainerParentId = containerParentIdTF model.Name = types.StringPointerValue(name) model.Labels = tfLabels + model.CreationTime = types.StringValue(creationTime.Format(time.RFC3339)) + model.UpdateTime = types.StringValue(updateTime.Format(time.RFC3339)) if state != nil { diags := diag.Diagnostics{} @@ -379,6 +402,8 @@ func mapFolderFields(ctx context.Context, containerId, name *string, labels *map diags.Append(state.SetAttribute(ctx, path.Root("container_id"), model.ContainerId)...) diags.Append(state.SetAttribute(ctx, path.Root("name"), model.Name)...) diags.Append(state.SetAttribute(ctx, path.Root("labels"), model.Labels)...) + diags.Append(state.SetAttribute(ctx, path.Root("creation_time"), model.CreationTime)...) + diags.Append(state.SetAttribute(ctx, path.Root("update_time"), model.UpdateTime)...) if diags.HasError() { return fmt.Errorf("update terraform state: %w", core.DiagsToError(diags)) } @@ -389,12 +414,12 @@ func mapFolderFields(ctx context.Context, containerId, name *string, labels *map // mapFolderCreateFields maps the Create Folder API response to the Terraform model and update the Terraform state func mapFolderCreateFields(ctx context.Context, resp *resourcemanager.FolderResponse, model *Model, state *tfsdk.State) error { - return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, model, state) + return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) } // mapFolderDetailsFields maps the GetDetails API response to the Terraform model and update the Terraform state func mapFolderDetailsFields(ctx context.Context, resp *resourcemanager.GetFolderDetailsResponse, model *Model, state *tfsdk.State) error { - return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, model, state) + return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) } func toMembersPayload(model *ResourceModel) (*[]resourcemanager.Member, error) { diff --git a/stackit/internal/services/resourcemanager/folder/resource_test.go b/stackit/internal/services/resourcemanager/folder/resource_test.go index e059bd914..b21deba6b 100644 --- a/stackit/internal/services/resourcemanager/folder/resource_test.go +++ b/stackit/internal/services/resourcemanager/folder/resource_test.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/uuid" @@ -17,11 +18,18 @@ import ( func TestMapFolderFields(t *testing.T) { testUUID := "73b2d741-bddd-471f-8d47-3d1aa677a19c" + // Create base timestamps for reuse + baseTime := time.Now() + createTime := baseTime + updateTime := baseTime.Add(1 * time.Hour) + tests := []struct { description string uuidContainerParentId bool respContainerId *string respName *string + respCreateTime *time.Time + respUpdateTime *time.Time labels *map[string]string parent *resourcemanager.Parent expected Model @@ -29,122 +37,146 @@ func TestMapFolderFields(t *testing.T) { isValid bool }{ { - "valid input with UUID parent ID", - true, - utils.Ptr("folder-cid-uuid"), - utils.Ptr("folder-name"), - &map[string]string{ + description: "valid input with UUID parent ID", + uuidContainerParentId: true, + respContainerId: utils.Ptr("folder-cid-uuid"), + respName: utils.Ptr("folder-name"), + respCreateTime: &createTime, + respUpdateTime: &updateTime, + labels: &map[string]string{ "env": "prod", }, - &resourcemanager.Parent{ + parent: &resourcemanager.Parent{ Id: utils.Ptr(testUUID), }, - Model{ + expected: Model{ Id: types.StringValue("folder-cid-uuid"), ContainerId: types.StringValue("folder-cid-uuid"), ContainerParentId: types.StringValue(testUUID), Name: types.StringValue("folder-name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - &map[string]string{ + expectedLabels: &map[string]string{ "env": "prod", }, - true, + isValid: true, }, { - "valid input with UUID parent ID no labels", - true, - utils.Ptr("folder-cid-uuid"), - utils.Ptr("folder-name"), - nil, - &resourcemanager.Parent{ + description: "valid input with UUID parent ID no labels", + uuidContainerParentId: true, + respContainerId: utils.Ptr("folder-cid-uuid"), + respName: utils.Ptr("folder-name"), + respCreateTime: &createTime, + respUpdateTime: &updateTime, + labels: nil, + parent: &resourcemanager.Parent{ Id: utils.Ptr(testUUID), }, - Model{ + expected: Model{ Id: types.StringValue("folder-cid-uuid"), ContainerId: types.StringValue("folder-cid-uuid"), ContainerParentId: types.StringValue(testUUID), Name: types.StringValue("folder-name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - nil, - true, + expectedLabels: nil, + isValid: true, }, { - "valid input with ContainerId as parent", - false, - utils.Ptr("folder-cid"), - utils.Ptr("folder-name"), - &map[string]string{ + description: "valid input with ContainerId as parent", + uuidContainerParentId: false, + respContainerId: utils.Ptr("folder-cid"), + respName: utils.Ptr("folder-name"), + respCreateTime: &createTime, + respUpdateTime: &updateTime, + labels: &map[string]string{ "env": "dev", }, - &resourcemanager.Parent{ + parent: &resourcemanager.Parent{ ContainerId: utils.Ptr("parent-container-id"), }, - Model{ + expected: Model{ Id: types.StringValue("folder-cid"), ContainerId: types.StringValue("folder-cid"), ContainerParentId: types.StringValue("parent-container-id"), Name: types.StringValue("folder-name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - &map[string]string{ + expectedLabels: &map[string]string{ "env": "dev", }, - true, + isValid: true, }, { - "valid input with ContainerId as parent no labels", - false, - utils.Ptr("folder-cid"), - utils.Ptr("folder-name"), - nil, - &resourcemanager.Parent{ + description: "valid input with ContainerId as parent no labels", + uuidContainerParentId: false, + respContainerId: utils.Ptr("folder-cid"), + respName: utils.Ptr("folder-name"), + respCreateTime: &createTime, + respUpdateTime: &updateTime, + labels: nil, + parent: &resourcemanager.Parent{ ContainerId: utils.Ptr("parent-container-id"), }, - Model{ + expected: Model{ Id: types.StringValue("folder-cid"), ContainerId: types.StringValue("folder-cid"), ContainerParentId: types.StringValue("parent-container-id"), Name: types.StringValue("folder-name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - nil, - true, + expectedLabels: nil, + isValid: true, }, { - "nil labels", - false, - utils.Ptr("folder-cid"), - utils.Ptr("folder-name"), - nil, - nil, - Model{ + description: "nil labels", + uuidContainerParentId: false, + respContainerId: utils.Ptr("folder-cid"), + respName: utils.Ptr("folder-name"), + respCreateTime: &createTime, + respUpdateTime: &updateTime, + labels: nil, + parent: nil, + expected: Model{ Id: types.StringValue("folder-cid"), ContainerId: types.StringValue("folder-cid"), ContainerParentId: types.StringNull(), Name: types.StringValue("folder-name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - nil, - true, + expectedLabels: nil, + isValid: true, }, { - "nil container ID, should fail", - false, - nil, - utils.Ptr("name"), - nil, - nil, - Model{}, - nil, - false, + description: "nil container ID, should fail", + uuidContainerParentId: false, + respContainerId: nil, + respName: utils.Ptr("name"), + respCreateTime: nil, + respUpdateTime: nil, + labels: nil, + parent: nil, + expected: Model{}, + expectedLabels: nil, + isValid: false, }, { - "empty container ID, should fail", - false, - utils.Ptr(""), - utils.Ptr("name"), - nil, - nil, - Model{}, - nil, - false, + description: "empty container ID, should fail", + uuidContainerParentId: false, + respContainerId: utils.Ptr(""), + respName: utils.Ptr("name"), + respCreateTime: nil, + respUpdateTime: nil, + labels: nil, + parent: nil, + expected: Model{}, + expectedLabels: nil, + isValid: false, }, } @@ -176,7 +208,17 @@ func TestMapFolderFields(t *testing.T) { ContainerParentId: containerParentId, } - err := mapFolderFields(context.Background(), tt.respContainerId, tt.respName, tt.labels, tt.parent, model, nil) + err := mapFolderFields( + context.Background(), + tt.respContainerId, + tt.respName, + tt.labels, + tt.parent, + tt.respCreateTime, + tt.respUpdateTime, + model, + nil, + ) if !tt.isValid && err == nil { t.Fatalf("Should have failed") @@ -198,6 +240,10 @@ func TestMapFolderCreateFields(t *testing.T) { labels := map[string]string{ "env": "prod", } + baseTime := time.Now() + createTime := baseTime + updateTime := baseTime.Add(1 * time.Hour) + resp := &resourcemanager.FolderResponse{ ContainerId: utils.Ptr("folder-id"), Name: utils.Ptr("my-folder"), @@ -205,6 +251,8 @@ func TestMapFolderCreateFields(t *testing.T) { Parent: &resourcemanager.Parent{ Id: utils.Ptr(uuid.New().String()), }, + CreationTime: &createTime, + UpdateTime: &updateTime, } model := Model{ @@ -223,6 +271,8 @@ func TestMapFolderCreateFields(t *testing.T) { ContainerParentId: types.StringValue(*resp.Parent.Id), Name: types.StringValue("my-folder"), Labels: cbLabels, + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), } diff := cmp.Diff(model, expected) if diff != "" { @@ -231,6 +281,10 @@ func TestMapFolderCreateFields(t *testing.T) { } func TestMapFolderDetailsFields(t *testing.T) { + baseTime := time.Now() + createTime := baseTime + updateTime := baseTime.Add(1 * time.Hour) + resp := &resourcemanager.GetFolderDetailsResponse{ ContainerId: utils.Ptr("folder-id"), Name: utils.Ptr("details-folder"), @@ -240,6 +294,8 @@ func TestMapFolderDetailsFields(t *testing.T) { Parent: &resourcemanager.Parent{ ContainerId: utils.Ptr("parent-container"), }, + CreationTime: &createTime, + UpdateTime: &updateTime, } var model Model @@ -256,6 +312,8 @@ func TestMapFolderDetailsFields(t *testing.T) { ContainerParentId: types.StringValue("parent-container"), Name: types.StringValue("details-folder"), Labels: tfLabels, + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), } diff := cmp.Diff(model, expected) diff --git a/stackit/internal/services/resourcemanager/project/datasource.go b/stackit/internal/services/resourcemanager/project/datasource.go index 9977c1af2..fc9040180 100644 --- a/stackit/internal/services/resourcemanager/project/datasource.go +++ b/stackit/internal/services/resourcemanager/project/datasource.go @@ -67,6 +67,8 @@ func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported", "name": "Project name.", "labels": `Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`, + "creation_time": "Date-time at which the folder was created.", + "update_time": "Date-time at which the folder was last modified.", } resp.Schema = schema.Schema{ @@ -122,6 +124,14 @@ func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest ), }, }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + }, + "update_time": schema.StringAttribute{ + Description: descriptions["update_time"], + Computed: true, + }, }, } } diff --git a/stackit/internal/services/resourcemanager/project/resource.go b/stackit/internal/services/resourcemanager/project/resource.go index efe4b4368..06f4c7a21 100644 --- a/stackit/internal/services/resourcemanager/project/resource.go +++ b/stackit/internal/services/resourcemanager/project/resource.go @@ -6,6 +6,7 @@ import ( "net/http" "regexp" "strings" + "time" resourcemanagerUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/utils" @@ -51,6 +52,8 @@ type Model struct { ContainerParentId types.String `tfsdk:"parent_container_id"` Name types.String `tfsdk:"name"` Labels types.Map `tfsdk:"labels"` + CreationTime types.String `tfsdk:"creation_time"` + UpdateTime types.String `tfsdk:"update_time"` } type ResourceModel struct { @@ -102,6 +105,8 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re "name": "Project name.", "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}. \nTo create a project within a STACKIT Network Area, setting the label `networkArea=` is required. This can not be changed after project creation.", "owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.", + "creation_time": "Date-time at which the folder was created.", + "update_time": "Date-time at which the folder was last modified.", } resp.Schema = schema.Schema{ @@ -170,6 +175,14 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: descriptions["owner_email"], Required: true, }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + }, + "update_time": schema.StringAttribute{ + Description: descriptions["update_time"], + Computed: true, + }, }, } } @@ -409,6 +422,8 @@ func mapProjectFields(ctx context.Context, projectResp *resourcemanager.GetProje model.ContainerId = types.StringValue(containerId) model.Name = types.StringPointerValue(projectResp.Name) model.Labels = labels + model.CreationTime = types.StringValue(projectResp.CreationTime.Format(time.RFC3339)) + model.UpdateTime = types.StringValue(projectResp.UpdateTime.Format(time.RFC3339)) if state != nil { diags := diag.Diagnostics{} @@ -418,6 +433,8 @@ func mapProjectFields(ctx context.Context, projectResp *resourcemanager.GetProje diags.Append(state.SetAttribute(ctx, path.Root("container_id"), model.ContainerId)...) diags.Append(state.SetAttribute(ctx, path.Root("name"), model.Name)...) diags.Append(state.SetAttribute(ctx, path.Root("labels"), model.Labels)...) + diags.Append(state.SetAttribute(ctx, path.Root("creation_time"), model.CreationTime)...) + diags.Append(state.SetAttribute(ctx, path.Root("update_time"), model.UpdateTime)...) if diags.HasError() { return fmt.Errorf("update terraform state: %w", core.DiagsToError(diags)) } diff --git a/stackit/internal/services/resourcemanager/project/resource_test.go b/stackit/internal/services/resourcemanager/project/resource_test.go index 2e6b49df7..28aaded68 100644 --- a/stackit/internal/services/resourcemanager/project/resource_test.go +++ b/stackit/internal/services/resourcemanager/project/resource_test.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/uuid" @@ -15,6 +16,10 @@ import ( func TestMapProjectFields(t *testing.T) { testUUID := uuid.New().String() + baseTime := time.Now() + createTime := baseTime + updateTime := baseTime.Add(1 * time.Hour) + tests := []struct { description string uuidContainerParentId bool @@ -24,26 +29,30 @@ func TestMapProjectFields(t *testing.T) { isValid bool }{ { - "default_ok", - false, - &resourcemanager.GetProjectResponse{ - ContainerId: utils.Ptr("cid"), - ProjectId: utils.Ptr("pid"), + description: "default_ok", + uuidContainerParentId: false, + projectResp: &resourcemanager.GetProjectResponse{ + ContainerId: utils.Ptr("cid"), + ProjectId: utils.Ptr("pid"), + CreationTime: &createTime, + UpdateTime: &updateTime, }, - Model{ + expected: Model{ Id: types.StringValue("cid"), ContainerId: types.StringValue("cid"), ProjectId: types.StringValue("pid"), ContainerParentId: types.StringNull(), Name: types.StringNull(), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - nil, - true, + expectedLabels: nil, + isValid: true, }, { - "container_parent_id_ok", - false, - &resourcemanager.GetProjectResponse{ + description: "container_parent_id_ok", + uuidContainerParentId: false, + projectResp: &resourcemanager.GetProjectResponse{ ContainerId: utils.Ptr("cid"), ProjectId: utils.Ptr("pid"), Labels: &map[string]string{ @@ -54,25 +63,29 @@ func TestMapProjectFields(t *testing.T) { ContainerId: utils.Ptr("parent_cid"), Id: utils.Ptr("parent_pid"), }, - Name: utils.Ptr("name"), + Name: utils.Ptr("name"), + CreationTime: &createTime, + UpdateTime: &updateTime, }, - Model{ + expected: Model{ Id: types.StringValue("cid"), ContainerId: types.StringValue("cid"), ProjectId: types.StringValue("pid"), ContainerParentId: types.StringValue("parent_cid"), Name: types.StringValue("name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - &map[string]string{ + expectedLabels: &map[string]string{ "label1": "ref1", "label2": "ref2", }, - true, + isValid: true, }, { - "uuid_parent_id_ok", - true, - &resourcemanager.GetProjectResponse{ + description: "uuid_parent_id_ok", + uuidContainerParentId: true, + projectResp: &resourcemanager.GetProjectResponse{ ContainerId: utils.Ptr("cid"), ProjectId: utils.Ptr("pid"), Labels: &map[string]string{ @@ -81,40 +94,45 @@ func TestMapProjectFields(t *testing.T) { }, Parent: &resourcemanager.Parent{ ContainerId: utils.Ptr("parent_cid"), - Id: utils.Ptr(testUUID), + Id: utils.Ptr(testUUID), // simulate UUID logic }, - Name: utils.Ptr("name"), + Name: utils.Ptr("name"), + CreationTime: &createTime, + UpdateTime: &updateTime, }, - Model{ + expected: Model{ Id: types.StringValue("cid"), ContainerId: types.StringValue("cid"), ProjectId: types.StringValue("pid"), ContainerParentId: types.StringValue(testUUID), Name: types.StringValue("name"), + CreationTime: types.StringValue(createTime.Format(time.RFC3339)), + UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - &map[string]string{ + expectedLabels: &map[string]string{ "label1": "ref1", "label2": "ref2", }, - true, + isValid: true, }, { - "response_nil_fail", - false, - nil, - Model{}, - nil, - false, + description: "response_nil_fail", + uuidContainerParentId: false, + projectResp: nil, + expected: Model{}, + expectedLabels: nil, + isValid: false, }, { - "no_resource_id", - false, - &resourcemanager.GetProjectResponse{}, - Model{}, - nil, - false, + description: "no_resource_id", + uuidContainerParentId: false, + projectResp: &resourcemanager.GetProjectResponse{}, + expected: Model{}, + expectedLabels: nil, + isValid: false, }, } + for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { if tt.expectedLabels == nil { @@ -129,13 +147,17 @@ func TestMapProjectFields(t *testing.T) { var containerParentId = types.StringNull() if tt.uuidContainerParentId { containerParentId = types.StringValue(testUUID) + } else if tt.projectResp != nil && tt.projectResp.Parent != nil && tt.projectResp.Parent.ContainerId != nil { + containerParentId = types.StringValue(*tt.projectResp.Parent.ContainerId) } + model := &Model{ ContainerId: tt.expected.ContainerId, ContainerParentId: containerParentId, } err := mapProjectFields(context.Background(), tt.projectResp, model, nil) + if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go index a9881da87..699ea1d36 100644 --- a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go +++ b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go @@ -124,6 +124,8 @@ func TestAccResourceManagerProjectContainerId(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "update_time"), ), }, // Data Source @@ -145,6 +147,8 @@ func TestAccResourceManagerProjectContainerId(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "creation_time"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "update_time"), ), }, // Import @@ -172,6 +176,8 @@ func TestAccResourceManagerProjectContainerId(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "update_time"), ), }, }, @@ -198,6 +204,8 @@ func TestAccResourceManagerProjectParentUUID(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "update_time"), ), }, // Data Source @@ -219,6 +227,8 @@ func TestAccResourceManagerProjectParentUUID(t *testing.T) { resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "creation_time"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "update_time"), ), }, // Import @@ -246,6 +256,8 @@ func TestAccResourceManagerProjectParentUUID(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "owner_email"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.example", "update_time"), ), }, }, @@ -270,6 +282,8 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "update_time"), ), }, // Data Source @@ -290,6 +304,8 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "creation_time"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "update_time"), ), }, // Import @@ -316,6 +332,8 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "owner_email"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "update_time"), ), }, }, @@ -340,6 +358,8 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "update_time"), ), }, // Data Source @@ -360,6 +380,8 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "creation_time"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "update_time"), ), }, // Import @@ -386,6 +408,8 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "owner_email"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "update_time"), ), }, }, From 47b524732a13afc3603a1d69f2f8bdc45c5f1282 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 2 Sep 2025 12:02:59 +0200 Subject: [PATCH 4/7] feat(resourcemanager): add folder_id to folder resource Signed-off-by: Mauritz Uphoff --- docs/data-sources/resourcemanager_folder.md | 1 + docs/resources/resourcemanager_folder.md | 1 + .../resourcemanager/folder/datasource.go | 8 +++ .../resourcemanager/folder/resource.go | 22 ++++-- .../resourcemanager/folder/resource_test.go | 71 ++++++++++++------- .../resourcemanager_acc_test.go | 6 ++ 6 files changed, 78 insertions(+), 31 deletions(-) diff --git a/docs/data-sources/resourcemanager_folder.md b/docs/data-sources/resourcemanager_folder.md index 0d60ca14e..b8e54fe26 100644 --- a/docs/data-sources/resourcemanager_folder.md +++ b/docs/data-sources/resourcemanager_folder.md @@ -28,6 +28,7 @@ data "stackit_resourcemanager_folder" "example" { ### Read-Only - `creation_time` (String) Date-time at which the folder was created. +- `folder_id` (String) Folder UUID identifier. Globally unique folder identifier - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}. - `name` (String) The name of the folder. diff --git a/docs/resources/resourcemanager_folder.md b/docs/resources/resourcemanager_folder.md index 983039233..50d1fa40c 100644 --- a/docs/resources/resourcemanager_folder.md +++ b/docs/resources/resourcemanager_folder.md @@ -40,5 +40,6 @@ resource "stackit_resourcemanager_folder" "example" { - `container_id` (String) Folder container ID. Globally unique, user-friendly identifier. - `creation_time` (String) Date-time at which the folder was created. +- `folder_id` (String) Folder UUID identifier. Globally unique folder identifier - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". - `update_time` (String) Date-time at which the folder was last modified. diff --git a/stackit/internal/services/resourcemanager/folder/datasource.go b/stackit/internal/services/resourcemanager/folder/datasource.go index f54b5b9ea..db5e4d10b 100644 --- a/stackit/internal/services/resourcemanager/folder/datasource.go +++ b/stackit/internal/services/resourcemanager/folder/datasource.go @@ -67,6 +67,7 @@ func (d *folderDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "main": "Resource Manager folder data source schema. To identify the folder, you need to provider the container_id.", "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", "container_id": "Folder container ID. Globally unique, user-friendly identifier.", + "folder_id": "Folder UUID identifier. Globally unique folder identifier", "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported.", "name": "The name of the folder.", "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}.", @@ -89,6 +90,13 @@ func (d *folderDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, }, Required: true, }, + "folder_id": schema.StringAttribute{ + Description: descriptions["folder_id"], + Computed: true, + Validators: []validator.String{ + validate.UUID(), + }, + }, "parent_container_id": schema.StringAttribute{ Description: descriptions["parent_container_id"], Computed: true, diff --git a/stackit/internal/services/resourcemanager/folder/resource.go b/stackit/internal/services/resourcemanager/folder/resource.go index 4824a4309..d6f77d1e5 100644 --- a/stackit/internal/services/resourcemanager/folder/resource.go +++ b/stackit/internal/services/resourcemanager/folder/resource.go @@ -45,6 +45,7 @@ const ( type Model struct { Id types.String `tfsdk:"id"` // needed by TF + FolderId types.String `tfsdk:"folder_id"` ContainerId types.String `tfsdk:"container_id"` ContainerParentId types.String `tfsdk:"parent_container_id"` Name types.String `tfsdk:"name"` @@ -99,6 +100,7 @@ func (r *folderResource) Schema(_ context.Context, _ resource.SchemaRequest, res "main": "Resource Manager folder resource schema.", "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", "container_id": "Folder container ID. Globally unique, user-friendly identifier.", + "folder_id": "Folder UUID identifier. Globally unique folder identifier", "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported.", "name": "The name of the folder.", "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}.", @@ -127,6 +129,16 @@ func (r *folderResource) Schema(_ context.Context, _ resource.SchemaRequest, res validate.NoSeparator(), }, }, + "folder_id": schema.StringAttribute{ + Description: descriptions["folder_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, "parent_container_id": schema.StringAttribute{ Description: descriptions["parent_container_id"], Required: true, @@ -351,7 +363,7 @@ func (r *folderResource) ImportState(ctx context.Context, req resource.ImportSta // mapFolderFields maps folder fields from a response into the Terraform model and optionally updates state. func mapFolderFields( ctx context.Context, - containerId, name *string, + containerId, name, folderId *string, labels *map[string]string, //nolint:gocritic containerParent *resourcemanager.Parent, creationTime *time.Time, @@ -388,6 +400,7 @@ func mapFolderFields( } model.Id = types.StringValue(*containerId) + model.FolderId = types.StringValue(*folderId) model.ContainerId = types.StringValue(*containerId) model.ContainerParentId = containerParentIdTF model.Name = types.StringPointerValue(name) @@ -398,8 +411,9 @@ func mapFolderFields( if state != nil { diags := diag.Diagnostics{} diags.Append(state.SetAttribute(ctx, path.Root("id"), model.Id)...) - diags.Append(state.SetAttribute(ctx, path.Root("parent_container_id"), model.ContainerParentId)...) + diags.Append(state.SetAttribute(ctx, path.Root("folder_id"), model.FolderId)...) diags.Append(state.SetAttribute(ctx, path.Root("container_id"), model.ContainerId)...) + diags.Append(state.SetAttribute(ctx, path.Root("parent_container_id"), model.ContainerParentId)...) diags.Append(state.SetAttribute(ctx, path.Root("name"), model.Name)...) diags.Append(state.SetAttribute(ctx, path.Root("labels"), model.Labels)...) diags.Append(state.SetAttribute(ctx, path.Root("creation_time"), model.CreationTime)...) @@ -414,12 +428,12 @@ func mapFolderFields( // mapFolderCreateFields maps the Create Folder API response to the Terraform model and update the Terraform state func mapFolderCreateFields(ctx context.Context, resp *resourcemanager.FolderResponse, model *Model, state *tfsdk.State) error { - return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) + return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.FolderId, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) } // mapFolderDetailsFields maps the GetDetails API response to the Terraform model and update the Terraform state func mapFolderDetailsFields(ctx context.Context, resp *resourcemanager.GetFolderDetailsResponse, model *Model, state *tfsdk.State) error { - return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) + return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.FolderId, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) } func toMembersPayload(model *ResourceModel) (*[]resourcemanager.Member, error) { diff --git a/stackit/internal/services/resourcemanager/folder/resource_test.go b/stackit/internal/services/resourcemanager/folder/resource_test.go index b21deba6b..b6eafdb82 100644 --- a/stackit/internal/services/resourcemanager/folder/resource_test.go +++ b/stackit/internal/services/resourcemanager/folder/resource_test.go @@ -16,7 +16,8 @@ import ( ) func TestMapFolderFields(t *testing.T) { - testUUID := "73b2d741-bddd-471f-8d47-3d1aa677a19c" + parentContainerUUID := uuid.New().String() + folderUUID := uuid.New().String() // Create base timestamps for reuse baseTime := time.Now() @@ -26,6 +27,7 @@ func TestMapFolderFields(t *testing.T) { tests := []struct { description string uuidContainerParentId bool + respFolderId *string respContainerId *string respName *string respCreateTime *time.Time @@ -39,7 +41,8 @@ func TestMapFolderFields(t *testing.T) { { description: "valid input with UUID parent ID", uuidContainerParentId: true, - respContainerId: utils.Ptr("folder-cid-uuid"), + respFolderId: &folderUUID, + respContainerId: utils.Ptr("folder-human-readable-id"), respName: utils.Ptr("folder-name"), respCreateTime: &createTime, respUpdateTime: &updateTime, @@ -47,12 +50,13 @@ func TestMapFolderFields(t *testing.T) { "env": "prod", }, parent: &resourcemanager.Parent{ - Id: utils.Ptr(testUUID), + Id: utils.Ptr(parentContainerUUID), }, expected: Model{ - Id: types.StringValue("folder-cid-uuid"), - ContainerId: types.StringValue("folder-cid-uuid"), - ContainerParentId: types.StringValue(testUUID), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue(folderUUID), + ContainerId: types.StringValue("folder-human-readable-id"), + ContainerParentId: types.StringValue(parentContainerUUID), Name: types.StringValue("folder-name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), @@ -65,18 +69,20 @@ func TestMapFolderFields(t *testing.T) { { description: "valid input with UUID parent ID no labels", uuidContainerParentId: true, - respContainerId: utils.Ptr("folder-cid-uuid"), + respFolderId: &folderUUID, + respContainerId: utils.Ptr("folder-human-readable-id"), respName: utils.Ptr("folder-name"), respCreateTime: &createTime, respUpdateTime: &updateTime, labels: nil, parent: &resourcemanager.Parent{ - Id: utils.Ptr(testUUID), + Id: utils.Ptr(parentContainerUUID), }, expected: Model{ - Id: types.StringValue("folder-cid-uuid"), - ContainerId: types.StringValue("folder-cid-uuid"), - ContainerParentId: types.StringValue(testUUID), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue(folderUUID), + ContainerId: types.StringValue("folder-human-readable-id"), + ContainerParentId: types.StringValue(parentContainerUUID), Name: types.StringValue("folder-name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), @@ -87,7 +93,8 @@ func TestMapFolderFields(t *testing.T) { { description: "valid input with ContainerId as parent", uuidContainerParentId: false, - respContainerId: utils.Ptr("folder-cid"), + respFolderId: &folderUUID, + respContainerId: utils.Ptr("folder-human-readable-id"), respName: utils.Ptr("folder-name"), respCreateTime: &createTime, respUpdateTime: &updateTime, @@ -98,8 +105,9 @@ func TestMapFolderFields(t *testing.T) { ContainerId: utils.Ptr("parent-container-id"), }, expected: Model{ - Id: types.StringValue("folder-cid"), - ContainerId: types.StringValue("folder-cid"), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue(folderUUID), + ContainerId: types.StringValue("folder-human-readable-id"), ContainerParentId: types.StringValue("parent-container-id"), Name: types.StringValue("folder-name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), @@ -113,7 +121,8 @@ func TestMapFolderFields(t *testing.T) { { description: "valid input with ContainerId as parent no labels", uuidContainerParentId: false, - respContainerId: utils.Ptr("folder-cid"), + respFolderId: &folderUUID, + respContainerId: utils.Ptr("folder-human-readable-id"), respName: utils.Ptr("folder-name"), respCreateTime: &createTime, respUpdateTime: &updateTime, @@ -122,8 +131,9 @@ func TestMapFolderFields(t *testing.T) { ContainerId: utils.Ptr("parent-container-id"), }, expected: Model{ - Id: types.StringValue("folder-cid"), - ContainerId: types.StringValue("folder-cid"), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue(folderUUID), + ContainerId: types.StringValue("folder-human-readable-id"), ContainerParentId: types.StringValue("parent-container-id"), Name: types.StringValue("folder-name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), @@ -135,15 +145,17 @@ func TestMapFolderFields(t *testing.T) { { description: "nil labels", uuidContainerParentId: false, - respContainerId: utils.Ptr("folder-cid"), + respFolderId: &folderUUID, + respContainerId: utils.Ptr("folder-human-readable-id"), respName: utils.Ptr("folder-name"), respCreateTime: &createTime, respUpdateTime: &updateTime, labels: nil, parent: nil, expected: Model{ - Id: types.StringValue("folder-cid"), - ContainerId: types.StringValue("folder-cid"), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue(folderUUID), + ContainerId: types.StringValue("folder-human-readable-id"), ContainerParentId: types.StringNull(), Name: types.StringValue("folder-name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), @@ -196,7 +208,7 @@ func TestMapFolderFields(t *testing.T) { // Simulate ContainerParentId configuration based on UUID detection logic var containerParentId basetypes.StringValue if tt.uuidContainerParentId { - containerParentId = types.StringValue(testUUID) + containerParentId = types.StringValue(parentContainerUUID) } else if tt.parent != nil && tt.parent.ContainerId != nil { containerParentId = types.StringValue(*tt.parent.ContainerId) } else { @@ -212,6 +224,7 @@ func TestMapFolderFields(t *testing.T) { context.Background(), tt.respContainerId, tt.respName, + tt.respFolderId, tt.labels, tt.parent, tt.respCreateTime, @@ -245,7 +258,8 @@ func TestMapFolderCreateFields(t *testing.T) { updateTime := baseTime.Add(1 * time.Hour) resp := &resourcemanager.FolderResponse{ - ContainerId: utils.Ptr("folder-id"), + FolderId: utils.Ptr("folder-uuid"), + ContainerId: utils.Ptr("folder-human-readable-id"), Name: utils.Ptr("my-folder"), Labels: &labels, Parent: &resourcemanager.Parent{ @@ -266,8 +280,9 @@ func TestMapFolderCreateFields(t *testing.T) { cbLabels, _ := conversion.ToTerraformStringMap(context.Background(), labels) expected := Model{ - Id: types.StringValue("folder-id"), - ContainerId: types.StringValue("folder-id"), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue("folder-uuid"), + ContainerId: types.StringValue("folder-human-readable-id"), ContainerParentId: types.StringValue(*resp.Parent.Id), Name: types.StringValue("my-folder"), Labels: cbLabels, @@ -286,7 +301,8 @@ func TestMapFolderDetailsFields(t *testing.T) { updateTime := baseTime.Add(1 * time.Hour) resp := &resourcemanager.GetFolderDetailsResponse{ - ContainerId: utils.Ptr("folder-id"), + FolderId: utils.Ptr("folder-uuid"), + ContainerId: utils.Ptr("folder-human-readable-id"), Name: utils.Ptr("details-folder"), Labels: &map[string]string{ "foo": "bar", @@ -307,8 +323,9 @@ func TestMapFolderDetailsFields(t *testing.T) { tfLabels, _ := conversion.ToTerraformStringMap(context.Background(), *resp.Labels) expected := Model{ - Id: types.StringValue("folder-id"), - ContainerId: types.StringValue("folder-id"), + Id: types.StringValue("folder-human-readable-id"), + FolderId: types.StringValue("folder-uuid"), + ContainerId: types.StringValue("folder-human-readable-id"), ContainerParentId: types.StringValue("parent-container"), Name: types.StringValue("details-folder"), Labels: tfLabels, diff --git a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go index 699ea1d36..1a3263079 100644 --- a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go +++ b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go @@ -281,6 +281,7 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "folder_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "update_time"), @@ -302,6 +303,7 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "folder_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "creation_time"), @@ -330,6 +332,7 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "folder_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "owner_email"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), @@ -357,6 +360,7 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "folder_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "update_time"), @@ -378,6 +382,7 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), + resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "folder_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "creation_time"), @@ -406,6 +411,7 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "folder_id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "owner_email"), resource.TestCheckResourceAttrSet("stackit_resourcemanager_folder.example", "creation_time"), From 99194a5538be80456928a26a31e8348cfd4bfa8f Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 2 Sep 2025 12:33:29 +0200 Subject: [PATCH 5/7] feat(resourcemanager): extend folder example Signed-off-by: Mauritz Uphoff --- docs/resources/resourcemanager_folder.md | 13 ++++++++++++- .../stackit_resourcemanager_folder/resource.tf | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/resources/resourcemanager_folder.md b/docs/resources/resourcemanager_folder.md index 50d1fa40c..8bdca52e4 100644 --- a/docs/resources/resourcemanager_folder.md +++ b/docs/resources/resourcemanager_folder.md @@ -17,10 +17,21 @@ Resource Manager folder resource schema. ```terraform resource "stackit_resourcemanager_folder" "example" { - name = "foo" + name = "example-folder" owner_email = "foo.bar@stackit.cloud" parent_container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } + +# Note: +# You can add projects under folders. +# However, when deleting a project, be aware: +# - Projects may remain "invisible" for up to 7 days after deletion +# - During this time, deleting the parent folder may fail because the project is still technically linked +resource "stackit_resourcemanager_project" "example_project" { + name = "example-project" + owner_email = "foo.bar@stackit.cloud" + parent_container_id = stackit_resourcemanager_folder.example.container_id +} ``` diff --git a/examples/resources/stackit_resourcemanager_folder/resource.tf b/examples/resources/stackit_resourcemanager_folder/resource.tf index 693c2e419..6905b5a78 100644 --- a/examples/resources/stackit_resourcemanager_folder/resource.tf +++ b/examples/resources/stackit_resourcemanager_folder/resource.tf @@ -1,5 +1,16 @@ resource "stackit_resourcemanager_folder" "example" { - name = "foo" + name = "example-folder" owner_email = "foo.bar@stackit.cloud" parent_container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# Note: +# You can add projects under folders. +# However, when deleting a project, be aware: +# - Projects may remain "invisible" for up to 7 days after deletion +# - During this time, deleting the parent folder may fail because the project is still technically linked +resource "stackit_resourcemanager_project" "example_project" { + name = "example-project" + owner_email = "foo.bar@stackit.cloud" + parent_container_id = stackit_resourcemanager_folder.example.container_id } \ No newline at end of file From e0c855a267ab9d2030c0361f3a8e044ab1b3699e Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Thu, 4 Sep 2025 13:39:27 +0200 Subject: [PATCH 6/7] review change Signed-off-by: Mauritz Uphoff --- docs/data-sources/resourcemanager_project.md | 4 ++-- docs/resources/resourcemanager_project.md | 4 ++-- .../internal/services/resourcemanager/project/datasource.go | 4 ++-- stackit/internal/services/resourcemanager/project/resource.go | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/data-sources/resourcemanager_project.md b/docs/data-sources/resourcemanager_project.md index 07841f22c..6b9ff69c9 100644 --- a/docs/data-sources/resourcemanager_project.md +++ b/docs/data-sources/resourcemanager_project.md @@ -29,9 +29,9 @@ data "stackit_resourcemanager_project" "example" { ### Read-Only -- `creation_time` (String) Date-time at which the folder was created. +- `creation_time` (String) Date-time at which the project was created. - `id` (String) Terraform's internal data source. ID. It is structured as "`container_id`". - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64} - `name` (String) Project name. - `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported -- `update_time` (String) Date-time at which the folder was last modified. +- `update_time` (String) Date-time at which the project was last modified. diff --git a/docs/resources/resourcemanager_project.md b/docs/resources/resourcemanager_project.md index a914ef895..382cc2f78 100644 --- a/docs/resources/resourcemanager_project.md +++ b/docs/resources/resourcemanager_project.md @@ -52,7 +52,7 @@ To create a project within a STACKIT Network Area, setting the label `networkAre ### Read-Only - `container_id` (String) Project container ID. Globally unique, user-friendly identifier. -- `creation_time` (String) Date-time at which the folder was created. +- `creation_time` (String) Date-time at which the project was created. - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". - `project_id` (String) Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project. -- `update_time` (String) Date-time at which the folder was last modified. +- `update_time` (String) Date-time at which the project was last modified. diff --git a/stackit/internal/services/resourcemanager/project/datasource.go b/stackit/internal/services/resourcemanager/project/datasource.go index fc9040180..02d3c6ece 100644 --- a/stackit/internal/services/resourcemanager/project/datasource.go +++ b/stackit/internal/services/resourcemanager/project/datasource.go @@ -67,8 +67,8 @@ func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported", "name": "Project name.", "labels": `Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`, - "creation_time": "Date-time at which the folder was created.", - "update_time": "Date-time at which the folder was last modified.", + "creation_time": "Date-time at which the project was created.", + "update_time": "Date-time at which the project was last modified.", } resp.Schema = schema.Schema{ diff --git a/stackit/internal/services/resourcemanager/project/resource.go b/stackit/internal/services/resourcemanager/project/resource.go index 06f4c7a21..228c437d2 100644 --- a/stackit/internal/services/resourcemanager/project/resource.go +++ b/stackit/internal/services/resourcemanager/project/resource.go @@ -105,8 +105,8 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re "name": "Project name.", "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}. \nTo create a project within a STACKIT Network Area, setting the label `networkArea=` is required. This can not be changed after project creation.", "owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.", - "creation_time": "Date-time at which the folder was created.", - "update_time": "Date-time at which the folder was last modified.", + "creation_time": "Date-time at which the project was created.", + "update_time": "Date-time at which the project was last modified.", } resp.Schema = schema.Schema{ From ccaca32bf30958ec9589b2e7246ace0e94a8b710 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Mon, 15 Sep 2025 16:21:17 +0200 Subject: [PATCH 7/7] review changes Signed-off-by: Mauritz Uphoff --- docs/data-sources/resourcemanager_folder.md | 3 + docs/resources/resourcemanager_folder.md | 8 + .../resource.tf | 8 + .../resourcemanager/folder/datasource.go | 7 +- .../resourcemanager/folder/resource.go | 85 +++-- .../resourcemanager/folder/resource_test.go | 305 ++++-------------- .../resourcemanager/project/datasource.go | 3 +- .../resourcemanager_acc_test.go | 20 +- 8 files changed, 155 insertions(+), 284 deletions(-) diff --git a/docs/data-sources/resourcemanager_folder.md b/docs/data-sources/resourcemanager_folder.md index b8e54fe26..3ada0e457 100644 --- a/docs/data-sources/resourcemanager_folder.md +++ b/docs/data-sources/resourcemanager_folder.md @@ -4,12 +4,15 @@ page_title: "stackit_resourcemanager_folder Data Source - stackit" subcategory: "" description: |- Resource Manager folder data source schema. To identify the folder, you need to provider the container_id. + ~> This datasource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. --- # stackit_resourcemanager_folder (Data Source) Resource Manager folder data source schema. To identify the folder, you need to provider the container_id. +~> This datasource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + ## Example Usage ```terraform diff --git a/docs/resources/resourcemanager_folder.md b/docs/resources/resourcemanager_folder.md index 8bdca52e4..aaa80afc7 100644 --- a/docs/resources/resourcemanager_folder.md +++ b/docs/resources/resourcemanager_folder.md @@ -32,6 +32,14 @@ resource "stackit_resourcemanager_project" "example_project" { owner_email = "foo.bar@stackit.cloud" parent_container_id = stackit_resourcemanager_folder.example.container_id } + +# Only use the import statement, if you want to import an existing resourcemanager folder +# Note: There will be a conflict which needs to be resolved manually. +# Must set a configuration value for the owner_email attribute as the provider has marked it as required. +import { + to = stackit_resourcemanager_folder.import-example + id = var.container_id +} ``` diff --git a/examples/resources/stackit_resourcemanager_folder/resource.tf b/examples/resources/stackit_resourcemanager_folder/resource.tf index 6905b5a78..d4d782fac 100644 --- a/examples/resources/stackit_resourcemanager_folder/resource.tf +++ b/examples/resources/stackit_resourcemanager_folder/resource.tf @@ -13,4 +13,12 @@ resource "stackit_resourcemanager_project" "example_project" { name = "example-project" owner_email = "foo.bar@stackit.cloud" parent_container_id = stackit_resourcemanager_folder.example.container_id +} + +# Only use the import statement, if you want to import an existing resourcemanager folder +# Note: There will be a conflict which needs to be resolved manually. +# Must set a configuration value for the owner_email attribute as the provider has marked it as required. +import { + to = stackit_resourcemanager_folder.import-example + id = var.container_id } \ No newline at end of file diff --git a/stackit/internal/services/resourcemanager/folder/datasource.go b/stackit/internal/services/resourcemanager/folder/datasource.go index db5e4d10b..0af4efa9a 100644 --- a/stackit/internal/services/resourcemanager/folder/datasource.go +++ b/stackit/internal/services/resourcemanager/folder/datasource.go @@ -24,7 +24,8 @@ import ( // Ensure the implementation satisfies the expected interfaces. var ( - _ datasource.DataSource = &folderDataSource{} + _ datasource.DataSource = &folderDataSource{} + _ datasource.DataSourceWithConfigure = &folderDataSource{} ) // NewFolderDataSource is a helper function to simplify the provider implementation. @@ -77,7 +78,7 @@ func (d *folderDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, } resp.Schema = schema.Schema{ - Description: descriptions["main"], + Description: features.AddBetaDescription(descriptions["main"], core.Datasource), Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Description: descriptions["id"], @@ -169,7 +170,7 @@ func (d *folderDataSource) Read(ctx context.Context, req datasource.ReadRequest, return } - err = mapFolderDetailsFields(ctx, folderResp, &model, &resp.State) + err = mapFolderFields(ctx, folderResp, &model, &resp.State) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading folder", fmt.Sprintf("Processing API response: %v", err)) return diff --git a/stackit/internal/services/resourcemanager/folder/resource.go b/stackit/internal/services/resourcemanager/folder/resource.go index d6f77d1e5..6154ab478 100644 --- a/stackit/internal/services/resourcemanager/folder/resource.go +++ b/stackit/internal/services/resourcemanager/folder/resource.go @@ -209,21 +209,32 @@ func (r *folderResource) Create(ctx context.Context, req resource.CreateRequest, return } - folderResp, err := r.client.CreateFolder(ctx).CreateFolderPayload(*payload).Execute() + folderCreateResp, err := r.client.CreateFolder(ctx).CreateFolderPayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating folder", fmt.Sprintf("Calling API: %v", err)) return } - err = mapFolderCreateFields(ctx, folderResp, &model.Model, &resp.State) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "API response processing error", err.Error()) + if folderCreateResp.ContainerId == nil || *folderCreateResp.ContainerId == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating folder", "Container ID is missing") return } // This sleep is currently needed due to the IAM Cache. time.Sleep(10 * time.Second) + folderGetResponse, err := r.client.GetFolderDetails(ctx, *folderCreateResp.ContainerId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating folder", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFolderFields(ctx, folderGetResponse, &model.Model, &resp.State) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "API response processing error", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) tflog.Info(ctx, "Folder created") } @@ -253,7 +264,7 @@ func (r *folderResource) Read(ctx context.Context, req resource.ReadRequest, res return } - err = mapFolderDetailsFields(ctx, folderResp, &model.Model, &resp.State) + err = mapFolderFields(ctx, folderResp, &model.Model, &resp.State) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading folder", fmt.Sprintf("Processing API response: %v", err)) return @@ -300,7 +311,7 @@ func (r *folderResource) Update(ctx context.Context, req resource.UpdateRequest, return } - err = mapFolderDetailsFields(ctx, folderResp, &model.Model, &resp.State) + err = mapFolderFields(ctx, folderResp, &model.Model, &resp.State) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating folder", fmt.Sprintf("Processing API response: %v", err)) return @@ -334,7 +345,7 @@ func (r *folderResource) Delete(ctx context.Context, req resource.DeleteRequest, ctx, &resp.Diagnostics, "Error deleting folder. Deletion may fail because associated projects remain hidden for up to 7 days after user deletion due to technical requirements.", - fmt.Sprintf("API call failed: %v", err), + fmt.Sprintf("Calling API: %v", err), ) return } @@ -363,22 +374,36 @@ func (r *folderResource) ImportState(ctx context.Context, req resource.ImportSta // mapFolderFields maps folder fields from a response into the Terraform model and optionally updates state. func mapFolderFields( ctx context.Context, - containerId, name, folderId *string, - labels *map[string]string, //nolint:gocritic - containerParent *resourcemanager.Parent, - creationTime *time.Time, - updateTime *time.Time, + folderGetResponse *resourcemanager.GetFolderDetailsResponse, model *Model, state *tfsdk.State, ) error { - if containerId == nil || *containerId == "" { - return fmt.Errorf("container id is present") + if folderGetResponse == nil { + return fmt.Errorf("folder get response is nil") + } + + var folderId string + if model.FolderId.ValueString() != "" { + folderId = model.FolderId.ValueString() + } else if folderGetResponse.FolderId != nil { + folderId = *folderGetResponse.FolderId + } else { + return fmt.Errorf("folder id not present") + } + + var containerId string + if model.ContainerId.ValueString() != "" { + containerId = model.ContainerId.ValueString() + } else if folderGetResponse.ContainerId != nil { + containerId = *folderGetResponse.ContainerId + } else { + return fmt.Errorf("container id not present") } var err error var tfLabels basetypes.MapValue - if labels != nil && len(*labels) > 0 { - tfLabels, err = conversion.ToTerraformStringMap(ctx, *labels) + if folderGetResponse.Labels != nil && len(*folderGetResponse.Labels) > 0 { + tfLabels, err = conversion.ToTerraformStringMap(ctx, *folderGetResponse.Labels) if err != nil { return fmt.Errorf("converting to StringValue map: %w", err) } @@ -387,26 +412,26 @@ func mapFolderFields( } var containerParentIdTF basetypes.StringValue - if containerParent != nil { + if folderGetResponse.Parent != nil { if _, err := uuid.Parse(model.ContainerParentId.ValueString()); err == nil { // the provided containerParent is the UUID identifier - containerParentIdTF = types.StringPointerValue(containerParent.Id) + containerParentIdTF = types.StringPointerValue(folderGetResponse.Parent.Id) } else { // the provided containerParent is the user-friendly container id - containerParentIdTF = types.StringPointerValue(containerParent.ContainerId) + containerParentIdTF = types.StringPointerValue(folderGetResponse.Parent.ContainerId) } } else { containerParentIdTF = types.StringNull() } - model.Id = types.StringValue(*containerId) - model.FolderId = types.StringValue(*folderId) - model.ContainerId = types.StringValue(*containerId) + model.Id = types.StringValue(containerId) + model.FolderId = types.StringValue(folderId) + model.ContainerId = types.StringValue(containerId) model.ContainerParentId = containerParentIdTF - model.Name = types.StringPointerValue(name) + model.Name = types.StringPointerValue(folderGetResponse.Name) model.Labels = tfLabels - model.CreationTime = types.StringValue(creationTime.Format(time.RFC3339)) - model.UpdateTime = types.StringValue(updateTime.Format(time.RFC3339)) + model.CreationTime = types.StringValue(folderGetResponse.CreationTime.Format(time.RFC3339)) + model.UpdateTime = types.StringValue(folderGetResponse.UpdateTime.Format(time.RFC3339)) if state != nil { diags := diag.Diagnostics{} @@ -426,16 +451,6 @@ func mapFolderFields( return nil } -// mapFolderCreateFields maps the Create Folder API response to the Terraform model and update the Terraform state -func mapFolderCreateFields(ctx context.Context, resp *resourcemanager.FolderResponse, model *Model, state *tfsdk.State) error { - return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.FolderId, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) -} - -// mapFolderDetailsFields maps the GetDetails API response to the Terraform model and update the Terraform state -func mapFolderDetailsFields(ctx context.Context, resp *resourcemanager.GetFolderDetailsResponse, model *Model, state *tfsdk.State) error { - return mapFolderFields(ctx, resp.ContainerId, resp.Name, resp.FolderId, resp.Labels, resp.Parent, resp.CreationTime, resp.UpdateTime, model, state) -} - func toMembersPayload(model *ResourceModel) (*[]resourcemanager.Member, error) { if model == nil { return nil, fmt.Errorf("nil model") diff --git a/stackit/internal/services/resourcemanager/folder/resource_test.go b/stackit/internal/services/resourcemanager/folder/resource_test.go index b6eafdb82..2b8a700ce 100644 --- a/stackit/internal/services/resourcemanager/folder/resource_test.go +++ b/stackit/internal/services/resourcemanager/folder/resource_test.go @@ -9,17 +9,13 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" ) func TestMapFolderFields(t *testing.T) { - parentContainerUUID := uuid.New().String() - folderUUID := uuid.New().String() - - // Create base timestamps for reuse + testUUID := uuid.New().String() baseTime := time.Now() createTime := baseTime updateTime := baseTime.Add(1 * time.Hour) @@ -27,63 +23,26 @@ func TestMapFolderFields(t *testing.T) { tests := []struct { description string uuidContainerParentId bool - respFolderId *string - respContainerId *string - respName *string - respCreateTime *time.Time - respUpdateTime *time.Time - labels *map[string]string - parent *resourcemanager.Parent + projectResp *resourcemanager.GetFolderDetailsResponse expected Model expectedLabels *map[string]string isValid bool }{ { - description: "valid input with UUID parent ID", - uuidContainerParentId: true, - respFolderId: &folderUUID, - respContainerId: utils.Ptr("folder-human-readable-id"), - respName: utils.Ptr("folder-name"), - respCreateTime: &createTime, - respUpdateTime: &updateTime, - labels: &map[string]string{ - "env": "prod", - }, - parent: &resourcemanager.Parent{ - Id: utils.Ptr(parentContainerUUID), - }, - expected: Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue(folderUUID), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringValue(parentContainerUUID), - Name: types.StringValue("folder-name"), - CreationTime: types.StringValue(createTime.Format(time.RFC3339)), - UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), - }, - expectedLabels: &map[string]string{ - "env": "prod", - }, - isValid: true, - }, - { - description: "valid input with UUID parent ID no labels", - uuidContainerParentId: true, - respFolderId: &folderUUID, - respContainerId: utils.Ptr("folder-human-readable-id"), - respName: utils.Ptr("folder-name"), - respCreateTime: &createTime, - respUpdateTime: &updateTime, - labels: nil, - parent: &resourcemanager.Parent{ - Id: utils.Ptr(parentContainerUUID), + description: "default_ok", + uuidContainerParentId: false, + projectResp: &resourcemanager.GetFolderDetailsResponse{ + ContainerId: utils.Ptr("cid"), + FolderId: utils.Ptr("fid"), + CreationTime: &createTime, + UpdateTime: &updateTime, }, expected: Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue(folderUUID), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringValue(parentContainerUUID), - Name: types.StringValue("folder-name"), + Id: types.StringValue("cid"), + ContainerId: types.StringValue("cid"), + FolderId: types.StringValue("fid"), + ContainerParentId: types.StringNull(), + Name: types.StringNull(), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, @@ -91,101 +50,83 @@ func TestMapFolderFields(t *testing.T) { isValid: true, }, { - description: "valid input with ContainerId as parent", + description: "container_parent_id_ok", uuidContainerParentId: false, - respFolderId: &folderUUID, - respContainerId: utils.Ptr("folder-human-readable-id"), - respName: utils.Ptr("folder-name"), - respCreateTime: &createTime, - respUpdateTime: &updateTime, - labels: &map[string]string{ - "env": "dev", - }, - parent: &resourcemanager.Parent{ - ContainerId: utils.Ptr("parent-container-id"), + projectResp: &resourcemanager.GetFolderDetailsResponse{ + ContainerId: utils.Ptr("cid"), + FolderId: utils.Ptr("fid"), + Labels: &map[string]string{ + "label1": "ref1", + "label2": "ref2", + }, + Parent: &resourcemanager.Parent{ + ContainerId: utils.Ptr("parent_cid"), + Id: utils.Ptr("parent_pid"), + }, + Name: utils.Ptr("name"), + CreationTime: &createTime, + UpdateTime: &updateTime, }, expected: Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue(folderUUID), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringValue("parent-container-id"), - Name: types.StringValue("folder-name"), + Id: types.StringValue("cid"), + ContainerId: types.StringValue("cid"), + FolderId: types.StringValue("fid"), + ContainerParentId: types.StringValue("parent_cid"), + Name: types.StringValue("name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, expectedLabels: &map[string]string{ - "env": "dev", + "label1": "ref1", + "label2": "ref2", }, isValid: true, }, { - description: "valid input with ContainerId as parent no labels", - uuidContainerParentId: false, - respFolderId: &folderUUID, - respContainerId: utils.Ptr("folder-human-readable-id"), - respName: utils.Ptr("folder-name"), - respCreateTime: &createTime, - respUpdateTime: &updateTime, - labels: nil, - parent: &resourcemanager.Parent{ - ContainerId: utils.Ptr("parent-container-id"), + description: "uuid_parent_id_ok", + uuidContainerParentId: true, + projectResp: &resourcemanager.GetFolderDetailsResponse{ + ContainerId: utils.Ptr("cid"), + FolderId: utils.Ptr("fid"), + Labels: &map[string]string{ + "label1": "ref1", + "label2": "ref2", + }, + Parent: &resourcemanager.Parent{ + ContainerId: utils.Ptr("parent_cid"), + Id: utils.Ptr(testUUID), // simulate UUID logic + }, + Name: utils.Ptr("name"), + CreationTime: &createTime, + UpdateTime: &updateTime, }, expected: Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue(folderUUID), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringValue("parent-container-id"), - Name: types.StringValue("folder-name"), + Id: types.StringValue("cid"), + ContainerId: types.StringValue("cid"), + FolderId: types.StringValue("fid"), + ContainerParentId: types.StringValue(testUUID), + Name: types.StringValue("name"), CreationTime: types.StringValue(createTime.Format(time.RFC3339)), UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), }, - expectedLabels: nil, - isValid: true, - }, - { - description: "nil labels", - uuidContainerParentId: false, - respFolderId: &folderUUID, - respContainerId: utils.Ptr("folder-human-readable-id"), - respName: utils.Ptr("folder-name"), - respCreateTime: &createTime, - respUpdateTime: &updateTime, - labels: nil, - parent: nil, - expected: Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue(folderUUID), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringNull(), - Name: types.StringValue("folder-name"), - CreationTime: types.StringValue(createTime.Format(time.RFC3339)), - UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), + expectedLabels: &map[string]string{ + "label1": "ref1", + "label2": "ref2", }, - expectedLabels: nil, - isValid: true, + isValid: true, }, { - description: "nil container ID, should fail", + description: "response_nil_fail", uuidContainerParentId: false, - respContainerId: nil, - respName: utils.Ptr("name"), - respCreateTime: nil, - respUpdateTime: nil, - labels: nil, - parent: nil, + projectResp: nil, expected: Model{}, expectedLabels: nil, isValid: false, }, { - description: "empty container ID, should fail", + description: "no_resource_id", uuidContainerParentId: false, - respContainerId: utils.Ptr(""), - respName: utils.Ptr("name"), - respCreateTime: nil, - respUpdateTime: nil, - labels: nil, - parent: nil, + projectResp: &resourcemanager.GetFolderDetailsResponse{}, expected: Model{}, expectedLabels: nil, isValid: false, @@ -194,7 +135,6 @@ func TestMapFolderFields(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - // Handle expected label conversion if tt.expectedLabels == nil { tt.expected.Labels = types.MapNull(types.StringType) } else { @@ -204,15 +144,11 @@ func TestMapFolderFields(t *testing.T) { } tt.expected.Labels = convertedLabels } - - // Simulate ContainerParentId configuration based on UUID detection logic - var containerParentId basetypes.StringValue + var containerParentId = types.StringNull() if tt.uuidContainerParentId { - containerParentId = types.StringValue(parentContainerUUID) - } else if tt.parent != nil && tt.parent.ContainerId != nil { - containerParentId = types.StringValue(*tt.parent.ContainerId) - } else { - containerParentId = types.StringNull() + containerParentId = types.StringValue(testUUID) + } else if tt.projectResp != nil && tt.projectResp.Parent != nil && tt.projectResp.Parent.ContainerId != nil { + containerParentId = types.StringValue(*tt.projectResp.Parent.ContainerId) } model := &Model{ @@ -220,18 +156,7 @@ func TestMapFolderFields(t *testing.T) { ContainerParentId: containerParentId, } - err := mapFolderFields( - context.Background(), - tt.respContainerId, - tt.respName, - tt.respFolderId, - tt.labels, - tt.parent, - tt.respCreateTime, - tt.respUpdateTime, - model, - nil, - ) + err := mapFolderFields(context.Background(), tt.projectResp, model, nil) if !tt.isValid && err == nil { t.Fatalf("Should have failed") @@ -249,96 +174,6 @@ func TestMapFolderFields(t *testing.T) { } } -func TestMapFolderCreateFields(t *testing.T) { - labels := map[string]string{ - "env": "prod", - } - baseTime := time.Now() - createTime := baseTime - updateTime := baseTime.Add(1 * time.Hour) - - resp := &resourcemanager.FolderResponse{ - FolderId: utils.Ptr("folder-uuid"), - ContainerId: utils.Ptr("folder-human-readable-id"), - Name: utils.Ptr("my-folder"), - Labels: &labels, - Parent: &resourcemanager.Parent{ - Id: utils.Ptr(uuid.New().String()), - }, - CreationTime: &createTime, - UpdateTime: &updateTime, - } - - model := Model{ - ContainerParentId: types.StringValue(*resp.Parent.Id), - } - - err := mapFolderCreateFields(context.Background(), resp, &model, nil) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - cbLabels, _ := conversion.ToTerraformStringMap(context.Background(), labels) - expected := Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue("folder-uuid"), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringValue(*resp.Parent.Id), - Name: types.StringValue("my-folder"), - Labels: cbLabels, - CreationTime: types.StringValue(createTime.Format(time.RFC3339)), - UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), - } - diff := cmp.Diff(model, expected) - if diff != "" { - t.Fatalf("mapFolderCreateFields() mismatch: %s", diff) - } -} - -func TestMapFolderDetailsFields(t *testing.T) { - baseTime := time.Now() - createTime := baseTime - updateTime := baseTime.Add(1 * time.Hour) - - resp := &resourcemanager.GetFolderDetailsResponse{ - FolderId: utils.Ptr("folder-uuid"), - ContainerId: utils.Ptr("folder-human-readable-id"), - Name: utils.Ptr("details-folder"), - Labels: &map[string]string{ - "foo": "bar", - }, - Parent: &resourcemanager.Parent{ - ContainerId: utils.Ptr("parent-container"), - }, - CreationTime: &createTime, - UpdateTime: &updateTime, - } - - var model Model - err := mapFolderDetailsFields(context.Background(), resp, &model, nil) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - tfLabels, _ := conversion.ToTerraformStringMap(context.Background(), *resp.Labels) - - expected := Model{ - Id: types.StringValue("folder-human-readable-id"), - FolderId: types.StringValue("folder-uuid"), - ContainerId: types.StringValue("folder-human-readable-id"), - ContainerParentId: types.StringValue("parent-container"), - Name: types.StringValue("details-folder"), - Labels: tfLabels, - CreationTime: types.StringValue(createTime.Format(time.RFC3339)), - UpdateTime: types.StringValue(updateTime.Format(time.RFC3339)), - } - - diff := cmp.Diff(model, expected) - if diff != "" { - t.Fatalf("mapFolderDetailsFields() mismatch: %s", diff) - } -} - func TestToCreatePayload(t *testing.T) { tests := []struct { description string diff --git a/stackit/internal/services/resourcemanager/project/datasource.go b/stackit/internal/services/resourcemanager/project/datasource.go index 02d3c6ece..0af3f8168 100644 --- a/stackit/internal/services/resourcemanager/project/datasource.go +++ b/stackit/internal/services/resourcemanager/project/datasource.go @@ -25,7 +25,8 @@ import ( // Ensure the implementation satisfies the expected interfaces. var ( - _ datasource.DataSource = &projectDataSource{} + _ datasource.DataSource = &projectDataSource{} + _ datasource.DataSourceWithConfigure = &projectDataSource{} ) // NewProjectDataSource is a helper function to simplify the provider implementation. diff --git a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go index 1a3263079..5490aefb1 100644 --- a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go +++ b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go @@ -141,9 +141,9 @@ func TestAccResourceManagerProjectContainerId(t *testing.T) { `, testutil.ResourceManagerProviderConfig(), resourceProject), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "name", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["name"])), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "parent_container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceProjectParentContainerId["parent_container_id"])), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_project.example", "container_id", "stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_project.example", "project_id", "stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "id"), @@ -224,8 +224,8 @@ func TestAccResourceManagerProjectParentUUID(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_project.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "parent_container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "project_id"), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_project.example", "container_id", "stackit_resourcemanager_project.example", "container_id"), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_project.example", "project_id", "stackit_resourcemanager_project.example", "project_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "creation_time"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_project.example", "update_time"), @@ -302,9 +302,9 @@ func TestAccResourceManagerFolderContainerId(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "name", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["name"])), resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.env", "prod"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "folder_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "parent_container_id", testutil.ConvertConfigVariable(testConfigResourceFolderParentContainerId["parent_container_id"])), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_folder.example", "container_id", "stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_folder.example", "project_id", "stackit_resourcemanager_folder.example", "project_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "creation_time"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "update_time"), @@ -382,8 +382,8 @@ func TestAccResourceManagerFolderParentUUID(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.%", "1"), resource.TestCheckResourceAttr("data.stackit_resourcemanager_folder.example", "labels.env", "prod"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "parent_container_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "folder_id"), - resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_folder.example", "container_id", "stackit_resourcemanager_folder.example", "container_id"), + resource.TestCheckResourceAttrPair("data.stackit_resourcemanager_folder.example", "project_id", "stackit_resourcemanager_folder.example", "project_id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "id"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "creation_time"), resource.TestCheckResourceAttrSet("data.stackit_resourcemanager_folder.example", "update_time"),