diff --git a/docs/data-sources/objectstorage_bucket.md b/docs/data-sources/objectstorage_bucket.md index a0f82a6c..49c4a5af 100644 --- a/docs/data-sources/objectstorage_bucket.md +++ b/docs/data-sources/objectstorage_bucket.md @@ -26,9 +26,12 @@ data "stackit_objectstorage_bucket" "example" { - `name` (String) The bucket name. It must be DNS conform. - `project_id` (String) STACKIT Project ID to which the bucket is associated. +- `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only - `id` (String) Terraform's internal data source identifier. It is structured as "`project_id`,`name`". - `url_path_style` (String) - `url_virtual_hosted_style` (String) + + diff --git a/docs/data-sources/objectstorage_credential.md b/docs/data-sources/objectstorage_credential.md index 5aabbc30..ed906a47 100644 --- a/docs/data-sources/objectstorage_credential.md +++ b/docs/data-sources/objectstorage_credential.md @@ -28,6 +28,7 @@ data "stackit_objectstorage_credentials_group" "example" { - `credential_id` (String) The credential ID. - `credentials_group_id` (String) The credential group ID. - `project_id` (String) STACKIT Project ID to which the credential group is associated. +- `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only @@ -36,3 +37,5 @@ data "stackit_objectstorage_credentials_group" "example" { - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`credentials_group_id`,`credential_id`". - `name` (String) - `secret_access_key` (String, Sensitive) + + diff --git a/docs/data-sources/objectstorage_credentials_group.md b/docs/data-sources/objectstorage_credentials_group.md index df94096c..1cb63550 100644 --- a/docs/data-sources/objectstorage_credentials_group.md +++ b/docs/data-sources/objectstorage_credentials_group.md @@ -25,6 +25,7 @@ data "stackit_objectstorage_credentials_group" "example" { ### Required - `project_id` (String) Object Storage Project ID to which the credentials group is associated. +- `region` (String) The resource region. If not defined, the provider region is used. ### Optional @@ -35,3 +36,5 @@ data "stackit_objectstorage_credentials_group" "example" { - `id` (String) Terraform's internal data source identifier. It is structured as "`project_id`,`credentials_group_id`". - `urn` (String) Credentials group uniform resource name (URN) + + diff --git a/docs/resources/objectstorage_bucket.md b/docs/resources/objectstorage_bucket.md index a57260bd..02f34d7e 100644 --- a/docs/resources/objectstorage_bucket.md +++ b/docs/resources/objectstorage_bucket.md @@ -27,8 +27,14 @@ resource "stackit_objectstorage_bucket" "example" { - `name` (String) The bucket name. It must be DNS conform. - `project_id` (String) STACKIT Project ID to which the bucket is associated. +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + ### Read-Only - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`name`". - `url_path_style` (String) - `url_virtual_hosted_style` (String) + + diff --git a/docs/resources/objectstorage_credential.md b/docs/resources/objectstorage_credential.md index 03c3c344..27a12c91 100644 --- a/docs/resources/objectstorage_credential.md +++ b/docs/resources/objectstorage_credential.md @@ -31,6 +31,7 @@ resource "stackit_objectstorage_credential" "example" { ### Optional - `expiration_timestamp` (String) Expiration timestamp, in RFC339 format without fractional seconds. Example: "2025-01-01T00:00:00Z". If not set, the credential never expires. +- `region` (String) The resource region. If not defined, the provider region is used. ### Read-Only @@ -39,3 +40,5 @@ resource "stackit_objectstorage_credential" "example" { - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`credentials_group_id`,`credential_id`". - `name` (String) - `secret_access_key` (String, Sensitive) + + diff --git a/docs/resources/objectstorage_credentials_group.md b/docs/resources/objectstorage_credentials_group.md index 4f7c0d74..c67990a6 100644 --- a/docs/resources/objectstorage_credentials_group.md +++ b/docs/resources/objectstorage_credentials_group.md @@ -27,8 +27,14 @@ resource "stackit_objectstorage_credentials_group" "example" { - `name` (String) The credentials group's display name. - `project_id` (String) Project ID to which the credentials group is associated. +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + ### Read-Only - `credentials_group_id` (String) The credentials group ID - `id` (String) Terraform's internal data source identifier. It is structured as "`project_id`,`credentials_group_id`". - `urn` (String) Credentials group uniform resource name (URN) + + diff --git a/go.mod b/go.mod index 6f0b5cdb..50febabc 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.2 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.1 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.17.0 - github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.11.1 + github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.0.0 github.com/stackitcloud/stackit-sdk-go/services/observability v0.2.1 github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.19.1 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.17.0 diff --git a/go.sum b/go.sum index 16532e96..e998b18a 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/stackitcloud/stackit-sdk-go/core v0.15.1 h1:hIj/k/JXEuYmud3VWo3lr7Cfj2hfl4gG9nUIzcaZ9pM= github.com/stackitcloud/stackit-sdk-go/core v0.15.1/go.mod h1:mDX1mSTsB3mP+tNBGcFNx6gH1mGBN4T+dVt+lcw7nlw= +github.com/stackitcloud/stackit-sdk-go/core v0.15.2-0.20250204115447-63b21f25e380 h1:qy9kkEavIBFi11ztFnj6w1suZtzSnXUAo0bMwqDrLJQ= +github.com/stackitcloud/stackit-sdk-go/core v0.15.2-0.20250204115447-63b21f25e380/go.mod h1:mDX1mSTsB3mP+tNBGcFNx6gH1mGBN4T+dVt+lcw7nlw= github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 h1:JVEx/ouHB6PlwGzQa3ywyDym1HTWo3WgrxAyXprCnuM= github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0/go.mod h1:nVllQfYODhX1q3bgwVTLO7wHOp+8NMLiKbn3u/Dg5nU= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.5.1 h1:MAB7z8Hr6nCNdPCiLy5uNOJB+1R/eYFseFNEQUYK7qc= @@ -169,8 +171,8 @@ github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.1 h1:J+GLgfDIDnNpq github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.1/go.mod h1:nuZK6OXyZ4zlGsC1gZDj9+ajJzzFi9vVgSSRQlEJAqA= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.17.0 h1:SXNkKaAsGOkr9C6bv2i7q3kucxL3kril+z2wnshlXK0= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.17.0/go.mod h1:5hMtm08NrL+QcgKl94zUDrY7VEzKRcvCJOEOvENBxqc= -github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.11.1 h1:Df3fTAHaVgyiiyp9LyTTQI8jXSVeGo49eW5ya4AATCY= -github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.11.1/go.mod h1:V2LEHKyTaaiEBi9L3v62mNQ7xyJSred4OK+himLJOZQ= +github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.0.0 h1:/0n2zcH1nMw2noroGhz0fgu2YqtNo9v3AsVhXMRtmtw= +github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.0.0/go.mod h1:0XumGX33DT6ItyD8yMlogSPWvpIuoqN7RZBrpUBPX+k= github.com/stackitcloud/stackit-sdk-go/services/observability v0.2.1 h1:sIz4wJIz6/9Eh6nSoi2sQ+Ef53iOrFsqLKIp2oRkmgo= github.com/stackitcloud/stackit-sdk-go/services/observability v0.2.1/go.mod h1:okcRTrNDTI3d7MQcYJMliK0qoXeLq0b1wvZuEqgJIWE= github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.19.1 h1:hwRkCCUSWMhKTc7fLakL89V6+9xkxsFQlRthVmrvi1U= diff --git a/stackit/internal/services/objectstorage/bucket/datasource.go b/stackit/internal/services/objectstorage/bucket/datasource.go index f2790d01..6aa39169 100644 --- a/stackit/internal/services/objectstorage/bucket/datasource.go +++ b/stackit/internal/services/objectstorage/bucket/datasource.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" @@ -29,7 +30,8 @@ func NewBucketDataSource() datasource.DataSource { // bucketDataSource is the data source implementation. type bucketDataSource struct { - client *objectstorage.APIClient + client *objectstorage.APIClient + providerData core.ProviderData } // Metadata returns the data source type name. @@ -44,7 +46,8 @@ func (r *bucketDataSource) Configure(ctx context.Context, req datasource.Configu return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.providerData, ok = req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) return @@ -52,15 +55,14 @@ func (r *bucketDataSource) Configure(ctx context.Context, req datasource.Configu var apiClient *objectstorage.APIClient var err error - if providerData.ObjectStorageCustomEndpoint != "" { + if r.providerData.ObjectStorageCustomEndpoint != "" { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.ObjectStorageCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.ObjectStorageCustomEndpoint), ) } else { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.Region), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -82,6 +84,7 @@ func (r *bucketDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "project_id": "STACKIT Project ID to which the bucket is associated.", "url_path_style": "URL in path style.", "url_virtual_hosted_style": "URL in virtual hosted style.", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -112,6 +115,11 @@ func (r *bucketDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "url_virtual_hosted_style": schema.StringAttribute{ Computed: true, }, + "region": schema.StringAttribute{ + // the region cannot be found automatically, so it has to be passed + Optional: true, + Description: descriptions["region"], + }, }, } } @@ -126,10 +134,18 @@ func (r *bucketDataSource) Read(ctx context.Context, req datasource.ReadRequest, } projectId := model.ProjectId.ValueString() bucketName := model.Name.ValueString() + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.Region + } else { + region = model.Region.ValueString() + } + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", bucketName) + ctx = tflog.SetField(ctx, "region", region) - bucketResp, err := r.client.GetBucket(ctx, projectId, bucketName).Execute() + bucketResp, err := r.client.GetBucket(ctx, projectId, region, bucketName).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.StatusNotFound { @@ -140,7 +156,7 @@ func (r *bucketDataSource) Read(ctx context.Context, req datasource.ReadRequest, } // Map response body to schema - err = mapFields(bucketResp, &model) + err = mapFields(bucketResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading bucket", fmt.Sprintf("Processing API payload: %v", err)) return diff --git a/stackit/internal/services/objectstorage/bucket/resource.go b/stackit/internal/services/objectstorage/bucket/resource.go index 284edbb1..9e5f4084 100644 --- a/stackit/internal/services/objectstorage/bucket/resource.go +++ b/stackit/internal/services/objectstorage/bucket/resource.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/path" @@ -28,6 +29,7 @@ var ( _ resource.Resource = &bucketResource{} _ resource.ResourceWithConfigure = &bucketResource{} _ resource.ResourceWithImportState = &bucketResource{} + _ resource.ResourceWithModifyPlan = &bucketResource{} ) type Model struct { @@ -36,6 +38,7 @@ type Model struct { ProjectId types.String `tfsdk:"project_id"` URLPathStyle types.String `tfsdk:"url_path_style"` URLVirtualHostedStyle types.String `tfsdk:"url_virtual_hosted_style"` + Region types.String `tfsdk:"region"` } // NewBucketResource is a helper function to simplify the provider implementation. @@ -45,7 +48,38 @@ func NewBucketResource() resource.Resource { // bucketResource is the resource implementation. type bucketResource struct { - client *objectstorage.APIClient + client *objectstorage.APIClient + providerData core.ProviderData +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *bucketResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.Region, resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } } // Metadata returns the resource type name. @@ -60,7 +94,8 @@ func (r *bucketResource) Configure(ctx context.Context, req resource.ConfigureRe return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.providerData, ok = req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) return @@ -68,15 +103,14 @@ func (r *bucketResource) Configure(ctx context.Context, req resource.ConfigureRe var apiClient *objectstorage.APIClient var err error - if providerData.ObjectStorageCustomEndpoint != "" { + if r.providerData.ObjectStorageCustomEndpoint != "" { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.ObjectStorageCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.ObjectStorageCustomEndpoint), ) } else { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.Region), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -98,6 +132,7 @@ func (r *bucketResource) Schema(_ context.Context, _ resource.SchemaRequest, res "project_id": "STACKIT Project ID to which the bucket is associated.", "url_path_style": "URL in path style.", "url_virtual_hosted_style": "URL in virtual hosted style.", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -139,6 +174,15 @@ func (r *bucketResource) Schema(_ context.Context, _ resource.SchemaRequest, res "url_virtual_hosted_style": schema.StringAttribute{ Computed: true, }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, }, } } @@ -153,31 +197,34 @@ func (r *bucketResource) Create(ctx context.Context, req resource.CreateRequest, } projectId := model.ProjectId.ValueString() bucketName := model.Name.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", bucketName) + ctx = tflog.SetField(ctx, "region", region) // Handle project init - err := enableProject(ctx, &model, r.client) + err := enableProject(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating bucket", fmt.Sprintf("Enabling object storage project before creation: %v", err)) return } // Create new bucket - _, err = r.client.CreateBucket(ctx, projectId, bucketName).Execute() + _, err = r.client.CreateBucket(ctx, projectId, region, bucketName).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating bucket", fmt.Sprintf("Calling API: %v", err)) return } - waitResp, err := wait.CreateBucketWaitHandler(ctx, r.client, projectId, bucketName).WaitWithContext(ctx) + waitResp, err := wait.CreateBucketWaitHandler(ctx, r.client, projectId, region, bucketName).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating bucket", fmt.Sprintf("Bucket creation waiting: %v", err)) return } // Map response body to schema - err = mapFields(waitResp, &model) + err = mapFields(waitResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating bucket", fmt.Sprintf("Processing API payload: %v", err)) return @@ -200,10 +247,13 @@ func (r *bucketResource) Read(ctx context.Context, req resource.ReadRequest, res } projectId := model.ProjectId.ValueString() bucketName := model.Name.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", bucketName) + ctx = tflog.SetField(ctx, "region", region) - bucketResp, err := r.client.GetBucket(ctx, projectId, bucketName).Execute() + bucketResp, err := r.client.GetBucket(ctx, projectId, region, bucketName).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.StatusNotFound { @@ -215,7 +265,7 @@ func (r *bucketResource) Read(ctx context.Context, req resource.ReadRequest, res } // Map response body to schema - err = mapFields(bucketResp, &model) + err = mapFields(bucketResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading bucket", fmt.Sprintf("Processing API payload: %v", err)) return @@ -246,15 +296,18 @@ func (r *bucketResource) Delete(ctx context.Context, req resource.DeleteRequest, } projectId := model.ProjectId.ValueString() bucketName := model.Name.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", bucketName) + ctx = tflog.SetField(ctx, "region", region) // Delete existing bucket - _, err := r.client.DeleteBucket(ctx, projectId, bucketName).Execute() + _, err := r.client.DeleteBucket(ctx, projectId, region, bucketName).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting bucket", fmt.Sprintf("Calling API: %v", err)) } - _, err = wait.DeleteBucketWaitHandler(ctx, r.client, projectId, bucketName).WaitWithContext(ctx) + _, err = wait.DeleteBucketWaitHandler(ctx, r.client, projectId, region, bucketName).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting bucket", fmt.Sprintf("Bucket deletion waiting: %v", err)) return @@ -279,7 +332,7 @@ func (r *bucketResource) ImportState(ctx context.Context, req resource.ImportSta tflog.Info(ctx, "ObjectStorage bucket state imported") } -func mapFields(bucketResp *objectstorage.GetBucketResponse, model *Model) error { +func mapFields(bucketResp *objectstorage.GetBucketResponse, model *Model, region string) error { if bucketResp == nil { return fmt.Errorf("response input is nil") } @@ -300,19 +353,20 @@ func mapFields(bucketResp *objectstorage.GetBucketResponse, model *Model) error ) model.URLPathStyle = types.StringPointerValue(bucket.UrlPathStyle) model.URLVirtualHostedStyle = types.StringPointerValue(bucket.UrlVirtualHostedStyle) + model.Region = types.StringValue(region) return nil } type objectStorageClient interface { - EnableServiceExecute(ctx context.Context, projectId string) (*objectstorage.ProjectStatus, error) + EnableServiceExecute(ctx context.Context, projectId, region string) (*objectstorage.ProjectStatus, error) } // enableProject enables object storage for the specified project. If the project is already enabled, nothing happens -func enableProject(ctx context.Context, model *Model, client objectStorageClient) error { +func enableProject(ctx context.Context, model *Model, region string, client objectStorageClient) error { projectId := model.ProjectId.ValueString() // From the object storage OAS: Creation will also be successful if the project is already enabled, but will not create a duplicate - _, err := client.EnableServiceExecute(ctx, projectId) + _, err := client.EnableServiceExecute(ctx, projectId, region) if err != nil { return fmt.Errorf("failed to create object storage project: %w", err) } diff --git a/stackit/internal/services/objectstorage/bucket/resource_test.go b/stackit/internal/services/objectstorage/bucket/resource_test.go index 0d9481ec..45b130ae 100644 --- a/stackit/internal/services/objectstorage/bucket/resource_test.go +++ b/stackit/internal/services/objectstorage/bucket/resource_test.go @@ -2,6 +2,7 @@ package objectstorage import ( "context" + _ "embed" "fmt" "testing" @@ -15,7 +16,7 @@ type objectStorageClientMocked struct { returnError bool } -func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, projectId string) (*objectstorage.ProjectStatus, error) { +func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, projectId, _ string) (*objectstorage.ProjectStatus, error) { if c.returnError { return nil, fmt.Errorf("create project failed") } @@ -43,6 +44,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), URLPathStyle: types.StringNull(), URLVirtualHostedStyle: types.StringNull(), + Region: types.StringValue("eu01"), }, true, }, @@ -60,6 +62,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), URLPathStyle: types.StringValue("url/path/style"), URLVirtualHostedStyle: types.StringValue("url/virtual/hosted/style"), + Region: types.StringValue("eu01"), }, true, }, @@ -77,6 +80,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), URLPathStyle: types.StringValue(""), URLVirtualHostedStyle: types.StringValue(""), + Region: types.StringValue("eu01"), }, true, }, @@ -99,7 +103,7 @@ func TestMapFields(t *testing.T) { ProjectId: tt.expected.ProjectId, Name: tt.expected.Name, } - err := mapFields(tt.input, model) + err := mapFields(tt.input, model, "eu01") if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -138,7 +142,7 @@ func TestEnableProject(t *testing.T) { client := &objectStorageClientMocked{ returnError: tt.enableFails, } - err := enableProject(context.Background(), &Model{}, client) + err := enableProject(context.Background(), &Model{}, "eu01", client) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/objectstorage/credential/datasource.go b/stackit/internal/services/objectstorage/credential/datasource.go index a715d75f..9080fbb0 100644 --- a/stackit/internal/services/objectstorage/credential/datasource.go +++ b/stackit/internal/services/objectstorage/credential/datasource.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -25,7 +26,8 @@ func NewCredentialDataSource() datasource.DataSource { // credentialDataSource is the resource implementation. type credentialDataSource struct { - client *objectstorage.APIClient + client *objectstorage.APIClient + providerData core.ProviderData } // Metadata returns the resource type name. @@ -40,7 +42,8 @@ func (r *credentialDataSource) Configure(ctx context.Context, req datasource.Con return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.providerData, ok = req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) return @@ -48,15 +51,14 @@ func (r *credentialDataSource) Configure(ctx context.Context, req datasource.Con var apiClient *objectstorage.APIClient var err error - if providerData.ObjectStorageCustomEndpoint != "" { + if r.providerData.ObjectStorageCustomEndpoint != "" { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.ObjectStorageCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.ObjectStorageCustomEndpoint), ) } else { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.Region), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -77,6 +79,7 @@ func (r *credentialDataSource) Schema(_ context.Context, _ datasource.SchemaRequ "credential_id": "The credential ID.", "credentials_group_id": "The credential group ID.", "project_id": "STACKIT Project ID to which the credential group is associated.", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -111,6 +114,11 @@ func (r *credentialDataSource) Schema(_ context.Context, _ datasource.SchemaRequ "expiration_timestamp": schema.StringAttribute{ Computed: true, }, + "region": schema.StringAttribute{ + // the region cannot be found automatically, so it has to be passed + Optional: true, + Description: descriptions["region"], + }, }, } } @@ -127,11 +135,19 @@ func (r *credentialDataSource) Read(ctx context.Context, req datasource.ReadRequ projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() credentialId := model.CredentialId.ValueString() + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.Region + } else { + region = model.Region.ValueString() + } + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) ctx = tflog.SetField(ctx, "credential_id", credentialId) + ctx = tflog.SetField(ctx, "region", region) - found, err := readCredentials(ctx, &model, r.client) + found, err := readCredentials(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Finding credential: %v", err)) return diff --git a/stackit/internal/services/objectstorage/credential/resource.go b/stackit/internal/services/objectstorage/credential/resource.go index d177ce3c..ba92a944 100644 --- a/stackit/internal/services/objectstorage/credential/resource.go +++ b/stackit/internal/services/objectstorage/credential/resource.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "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/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/path" @@ -29,6 +30,7 @@ var ( _ resource.Resource = &credentialResource{} _ resource.ResourceWithConfigure = &credentialResource{} _ resource.ResourceWithImportState = &credentialResource{} + _ resource.ResourceWithModifyPlan = &credentialResource{} ) type Model struct { @@ -40,6 +42,7 @@ type Model struct { AccessKey types.String `tfsdk:"access_key"` SecretAccessKey types.String `tfsdk:"secret_access_key"` ExpirationTimestamp types.String `tfsdk:"expiration_timestamp"` + Region types.String `tfsdk:"region"` } // NewCredentialResource is a helper function to simplify the provider implementation. @@ -49,7 +52,38 @@ func NewCredentialResource() resource.Resource { // credentialResource is the resource implementation. type credentialResource struct { - client *objectstorage.APIClient + client *objectstorage.APIClient + providerData core.ProviderData +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *credentialResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.Region, resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } } // Metadata returns the resource type name. @@ -64,7 +98,8 @@ func (r *credentialResource) Configure(ctx context.Context, req resource.Configu return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.providerData, ok = req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) return @@ -72,15 +107,14 @@ func (r *credentialResource) Configure(ctx context.Context, req resource.Configu var apiClient *objectstorage.APIClient var err error - if providerData.ObjectStorageCustomEndpoint != "" { + if r.providerData.ObjectStorageCustomEndpoint != "" { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.ObjectStorageCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.ObjectStorageCustomEndpoint), ) } else { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.Region), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -102,6 +136,7 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, "credentials_group_id": "The credential group ID.", "project_id": "STACKIT Project ID to which the credential group is associated.", "expiration_timestamp": "Expiration timestamp, in RFC339 format without fractional seconds. Example: \"2025-01-01T00:00:00Z\". If not set, the credential never expires.", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -169,6 +204,15 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, stringplanmodifier.UseStateForUnknown(), }, }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, }, } } @@ -183,11 +227,14 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ } projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) + ctx = tflog.SetField(ctx, "region", region) // Handle project init - err := enableProject(ctx, &model, r.client) + err := enableProject(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Enabling object storage project before creation: %v", err)) return @@ -200,7 +247,7 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ return } // Create new credential - credentialResp, err := r.client.CreateAccessKey(ctx, projectId).CredentialsGroup(credentialsGroupId).CreateAccessKeyPayload(*payload).Execute() + credentialResp, err := r.client.CreateAccessKey(ctx, projectId, region).CredentialsGroup(credentialsGroupId).CreateAccessKeyPayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Calling API: %v", err)) return @@ -213,7 +260,7 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ ctx = tflog.SetField(ctx, "credential_id", credentialId) // Map response body to schema - err = mapFields(credentialResp, &model) + err = mapFields(credentialResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Processing API payload: %v", err)) return @@ -238,11 +285,14 @@ func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest, projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() credentialId := model.CredentialId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) ctx = tflog.SetField(ctx, "credential_id", credentialId) + ctx = tflog.SetField(ctx, "region", region) - found, err := readCredentials(ctx, &model, r.client) + found, err := readCredentials(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Finding credential: %v", err)) return @@ -279,12 +329,15 @@ func (r *credentialResource) Delete(ctx context.Context, req resource.DeleteRequ projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() credentialId := model.CredentialId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) ctx = tflog.SetField(ctx, "credential_id", credentialId) + ctx = tflog.SetField(ctx, "region", region) // Delete existing credential - _, err := r.client.DeleteAccessKey(ctx, projectId, credentialId).CredentialsGroup(credentialsGroupId).Execute() + _, err := r.client.DeleteAccessKey(ctx, projectId, region, credentialId).CredentialsGroup(credentialsGroupId).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting credential", fmt.Sprintf("Calling API: %v", err)) } @@ -311,15 +364,15 @@ func (r *credentialResource) ImportState(ctx context.Context, req resource.Impor } type objectStorageClient interface { - EnableServiceExecute(ctx context.Context, projectId string) (*objectstorage.ProjectStatus, error) + EnableServiceExecute(ctx context.Context, projectId, region string) (*objectstorage.ProjectStatus, error) } // enableProject enables object storage for the specified project. If the project is already enabled, nothing happens -func enableProject(ctx context.Context, model *Model, client objectStorageClient) error { +func enableProject(ctx context.Context, model *Model, region string, client objectStorageClient) error { projectId := model.ProjectId.ValueString() // From the object storage OAS: Creation will also be successful if the project is already enabled, but will not create a duplicate - _, err := client.EnableServiceExecute(ctx, projectId) + _, err := client.EnableServiceExecute(ctx, projectId, region) if err != nil { return fmt.Errorf("failed to create object storage project: %w", err) } @@ -348,7 +401,7 @@ func toCreatePayload(model *Model) (*objectstorage.CreateAccessKeyPayload, error }, nil } -func mapFields(credentialResp *objectstorage.CreateAccessKeyResponse, model *Model) error { +func mapFields(credentialResp *objectstorage.CreateAccessKeyResponse, model *Model, region string) error { if credentialResp == nil { return fmt.Errorf("response input is nil") } @@ -389,18 +442,19 @@ func mapFields(credentialResp *objectstorage.CreateAccessKeyResponse, model *Mod model.Name = types.StringPointerValue(credentialResp.DisplayName) model.AccessKey = types.StringPointerValue(credentialResp.AccessKey) model.SecretAccessKey = types.StringPointerValue(credentialResp.SecretAccessKey) + model.Region = types.StringValue(region) return nil } // readCredentials gets all the existing credentials for the specified credentials group, // finds the credential that is being read and updates the state. // Returns True if the credential was found, False otherwise. -func readCredentials(ctx context.Context, model *Model, client *objectstorage.APIClient) (bool, error) { +func readCredentials(ctx context.Context, model *Model, region string, client *objectstorage.APIClient) (bool, error) { projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() credentialId := model.CredentialId.ValueString() - credentialsGroupResp, err := client.ListAccessKeys(ctx, projectId).CredentialsGroup(credentialsGroupId).Execute() + credentialsGroupResp, err := client.ListAccessKeys(ctx, projectId, region).CredentialsGroup(credentialsGroupId).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.StatusNotFound { @@ -443,6 +497,7 @@ func readCredentials(ctx context.Context, model *Model, client *objectstorage.AP } break } + model.Region = types.StringValue(region) return foundCredential, nil } diff --git a/stackit/internal/services/objectstorage/credential/resource_test.go b/stackit/internal/services/objectstorage/credential/resource_test.go index 9cf8bfc2..c8028e2b 100644 --- a/stackit/internal/services/objectstorage/credential/resource_test.go +++ b/stackit/internal/services/objectstorage/credential/resource_test.go @@ -20,7 +20,7 @@ type objectStorageClientMocked struct { returnError bool } -func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, projectId string) (*objectstorage.ProjectStatus, error) { +func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, projectId, _ string) (*objectstorage.ProjectStatus, error) { if c.returnError { return nil, fmt.Errorf("create project failed") } @@ -51,6 +51,7 @@ func TestMapFields(t *testing.T) { AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), ExpirationTimestamp: types.StringNull(), + Region: types.StringValue("eu01"), }, true, }, @@ -71,6 +72,7 @@ func TestMapFields(t *testing.T) { AccessKey: types.StringValue("key"), SecretAccessKey: types.StringValue("secret-key"), ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + Region: types.StringValue("eu01"), }, true, }, @@ -90,6 +92,7 @@ func TestMapFields(t *testing.T) { AccessKey: types.StringValue(""), SecretAccessKey: types.StringValue(""), ExpirationTimestamp: types.StringNull(), + Region: types.StringValue("eu01"), }, true, }, @@ -106,6 +109,7 @@ func TestMapFields(t *testing.T) { Name: types.StringNull(), AccessKey: types.StringNull(), ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + Region: types.StringValue("eu01"), }, true, }, @@ -131,7 +135,7 @@ func TestMapFields(t *testing.T) { CredentialsGroupId: tt.expected.CredentialsGroupId, CredentialId: tt.expected.CredentialId, } - err := mapFields(tt.input, model) + err := mapFields(tt.input, model, "eu01") if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -196,7 +200,7 @@ func TestEnableProject(t *testing.T) { CredentialsGroupId: tt.expected.CredentialsGroupId, CredentialId: tt.expected.CredentialId, } - err := enableProject(context.Background(), model, client) + err := enableProject(context.Background(), model, "eu01", client) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -242,6 +246,7 @@ func TestReadCredentials(t *testing.T) { AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), ExpirationTimestamp: types.StringNull(), + Region: types.StringValue("eu01"), }, true, false, @@ -277,6 +282,7 @@ func TestReadCredentials(t *testing.T) { AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + Region: types.StringValue("eu01"), }, true, false, @@ -312,6 +318,7 @@ func TestReadCredentials(t *testing.T) { AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + Region: types.StringValue("eu01"), }, true, false, @@ -322,7 +329,9 @@ func TestReadCredentials(t *testing.T) { &objectstorage.ListAccessKeysResponse{ AccessKeys: &[]objectstorage.AccessKey{}, }, - Model{}, + Model{ + Region: types.StringValue("eu01"), + }, false, false, true, @@ -351,7 +360,9 @@ func TestReadCredentials(t *testing.T) { }, }, }, - Model{}, + Model{ + Region: types.StringValue("eu01"), + }, false, false, true, @@ -413,7 +424,7 @@ func TestReadCredentials(t *testing.T) { CredentialsGroupId: tt.expectedModel.CredentialsGroupId, CredentialId: tt.expectedModel.CredentialId, } - found, err := readCredentials(context.Background(), model, client) + found, err := readCredentials(context.Background(), model, "eu01", client) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/objectstorage/credentialsgroup/datasource.go b/stackit/internal/services/objectstorage/credentialsgroup/datasource.go index 77fc2a51..0e8f88ab 100644 --- a/stackit/internal/services/objectstorage/credentialsgroup/datasource.go +++ b/stackit/internal/services/objectstorage/credentialsgroup/datasource.go @@ -6,8 +6,10 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "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/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" @@ -27,7 +29,8 @@ func NewCredentialsGroupDataSource() datasource.DataSource { // credentialsGroupDataSource is the data source implementation. type credentialsGroupDataSource struct { - client *objectstorage.APIClient + client *objectstorage.APIClient + providerData core.ProviderData } // Metadata returns the data source type name. @@ -42,7 +45,8 @@ func (r *credentialsGroupDataSource) Configure(ctx context.Context, req datasour return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.providerData, ok = req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) return @@ -50,15 +54,14 @@ func (r *credentialsGroupDataSource) Configure(ctx context.Context, req datasour var apiClient *objectstorage.APIClient var err error - if providerData.ObjectStorageCustomEndpoint != "" { + if r.providerData.ObjectStorageCustomEndpoint != "" { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.ObjectStorageCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.ObjectStorageCustomEndpoint), ) } else { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.Region), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -80,6 +83,7 @@ func (r *credentialsGroupDataSource) Schema(_ context.Context, _ datasource.Sche "name": "The credentials group's display name.", "project_id": "Object Storage Project ID to which the credentials group is associated.", "urn": "Credentials group uniform resource name (URN)", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -111,6 +115,11 @@ func (r *credentialsGroupDataSource) Schema(_ context.Context, _ datasource.Sche Computed: true, Description: descriptions["urn"], }, + "region": schema.StringAttribute{ + // the region cannot be found automatically, so it has to be passed + Optional: true, + Description: descriptions["region"], + }, }, } } @@ -125,10 +134,18 @@ func (r *credentialsGroupDataSource) Read(ctx context.Context, req datasource.Re } projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.Region + } else { + region = model.Region.ValueString() + } + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) + ctx = tflog.SetField(ctx, "region", region) - found, err := readCredentialsGroups(ctx, &model, r.client) + found, err := readCredentialsGroups(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credentials group", fmt.Sprintf("getting credential group from list of credentials groups: %v", err)) return @@ -139,6 +156,10 @@ func (r *credentialsGroupDataSource) Read(ctx context.Context, req datasource.Re return } + // update the region attribute manually, as it is not contained in the + // server response + model.Region = types.StringValue(region) + // Set refreshed state diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) diff --git a/stackit/internal/services/objectstorage/credentialsgroup/resource.go b/stackit/internal/services/objectstorage/credentialsgroup/resource.go index b502b701..688891b4 100644 --- a/stackit/internal/services/objectstorage/credentialsgroup/resource.go +++ b/stackit/internal/services/objectstorage/credentialsgroup/resource.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + coreutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/path" @@ -28,6 +29,7 @@ var ( _ resource.Resource = &credentialsGroupResource{} _ resource.ResourceWithConfigure = &credentialsGroupResource{} _ resource.ResourceWithImportState = &credentialsGroupResource{} + _ resource.ResourceWithModifyPlan = &credentialsGroupResource{} ) type Model struct { @@ -36,6 +38,7 @@ type Model struct { Name types.String `tfsdk:"name"` ProjectId types.String `tfsdk:"project_id"` URN types.String `tfsdk:"urn"` + Region types.String `tfsdk:"region"` } // NewCredentialsGroupResource is a helper function to simplify the provider implementation. @@ -45,7 +48,38 @@ func NewCredentialsGroupResource() resource.Resource { // credentialsGroupResource is the resource implementation. type credentialsGroupResource struct { - client *objectstorage.APIClient + client *objectstorage.APIClient + providerData core.ProviderData +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *credentialsGroupResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + coreutils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.Region, resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } } // Metadata returns the resource type name. @@ -60,7 +94,8 @@ func (r *credentialsGroupResource) Configure(ctx context.Context, req resource.C return } - providerData, ok := req.ProviderData.(core.ProviderData) + var ok bool + r.providerData, ok = req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) return @@ -68,15 +103,14 @@ func (r *credentialsGroupResource) Configure(ctx context.Context, req resource.C var apiClient *objectstorage.APIClient var err error - if providerData.ObjectStorageCustomEndpoint != "" { + if r.providerData.ObjectStorageCustomEndpoint != "" { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithEndpoint(providerData.ObjectStorageCustomEndpoint), + config.WithCustomAuth(r.providerData.RoundTripper), + config.WithEndpoint(r.providerData.ObjectStorageCustomEndpoint), ) } else { apiClient, err = objectstorage.NewAPIClient( - config.WithCustomAuth(providerData.RoundTripper), - config.WithRegion(providerData.Region), + config.WithCustomAuth(r.providerData.RoundTripper), ) } @@ -98,6 +132,7 @@ func (r *credentialsGroupResource) Schema(_ context.Context, _ resource.SchemaRe "name": "The credentials group's display name.", "project_id": "Project ID to which the credentials group is associated.", "urn": "Credentials group uniform resource name (URN)", + "region": "The resource region. If not defined, the provider region is used.", } resp.Schema = schema.Schema{ @@ -138,6 +173,15 @@ func (r *credentialsGroupResource) Schema(_ context.Context, _ resource.SchemaRe Description: descriptions["urn"], Computed: true, }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, }, } } @@ -152,29 +196,32 @@ func (r *credentialsGroupResource) Create(ctx context.Context, req resource.Crea } projectId := model.ProjectId.ValueString() credentialsGroupName := model.Name.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "name", credentialsGroupName) + ctx = tflog.SetField(ctx, "region", region) createCredentialsGroupPayload := objectstorage.CreateCredentialsGroupPayload{ DisplayName: utils.Ptr(credentialsGroupName), } // Handle project init - err := enableProject(ctx, &model, r.client) + err := enableProject(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credentials group", fmt.Sprintf("Enabling object storage project before creation: %v", err)) return } // Create new credentials group - got, err := r.client.CreateCredentialsGroup(ctx, projectId).CreateCredentialsGroupPayload(createCredentialsGroupPayload).Execute() + got, err := r.client.CreateCredentialsGroup(ctx, projectId, region).CreateCredentialsGroupPayload(createCredentialsGroupPayload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credentials group", fmt.Sprintf("Calling API: %v", err)) return } // Map response body to schema - err = mapFields(got, &model) + err = mapFields(got, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credentialsGroup", fmt.Sprintf("Processing API payload: %v", err)) return @@ -197,10 +244,13 @@ func (r *credentialsGroupResource) Read(ctx context.Context, req resource.ReadRe } projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) + ctx = tflog.SetField(ctx, "region", region) - found, err := readCredentialsGroups(ctx, &model, r.client) + found, err := readCredentialsGroups(ctx, &model, region, r.client) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credentialsGroup", fmt.Sprintf("getting credential group from list of credentials groups: %v", err)) return @@ -209,6 +259,8 @@ func (r *credentialsGroupResource) Read(ctx context.Context, req resource.ReadRe resp.State.RemoveResource(ctx) return } + // update the region manually + model.Region = types.StringValue(region) // Set refreshed state diags = resp.State.Set(ctx, model) @@ -235,11 +287,14 @@ func (r *credentialsGroupResource) Delete(ctx context.Context, req resource.Dele } projectId := model.ProjectId.ValueString() credentialsGroupId := model.CredentialsGroupId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "credentials_group_id", credentialsGroupId) + ctx = tflog.SetField(ctx, "region", region) // Delete existing credentials group - _, err := r.client.DeleteCredentialsGroup(ctx, projectId, credentialsGroupId).Execute() + _, err := r.client.DeleteCredentialsGroup(ctx, projectId, region, credentialsGroupId).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting credentials group", fmt.Sprintf("Calling API: %v", err)) } @@ -264,7 +319,7 @@ func (r *credentialsGroupResource) ImportState(ctx context.Context, req resource tflog.Info(ctx, "ObjectStorage credentials group state imported") } -func mapFields(credentialsGroupResp *objectstorage.CreateCredentialsGroupResponse, model *Model) error { +func mapFields(credentialsGroupResp *objectstorage.CreateCredentialsGroupResponse, model *Model, region string) error { if credentialsGroupResp == nil { return fmt.Errorf("response input is nil") } @@ -280,12 +335,13 @@ func mapFields(credentialsGroupResp *objectstorage.CreateCredentialsGroupRespons if err != nil { return err } + model.Region = types.StringValue(region) return nil } func mapCredentialsGroup(credentialsGroup objectstorage.CredentialsGroup, model *Model) error { var credentialsGroupId string - if model.CredentialsGroupId.ValueString() != "" { + if !coreutils.IsUndefined(model.CredentialsGroupId) { credentialsGroupId = model.CredentialsGroupId.ValueString() } else if credentialsGroup.CredentialsGroupId != nil { credentialsGroupId = *credentialsGroup.CredentialsGroupId @@ -307,16 +363,16 @@ func mapCredentialsGroup(credentialsGroup objectstorage.CredentialsGroup, model } type objectStorageClient interface { - EnableServiceExecute(ctx context.Context, projectId string) (*objectstorage.ProjectStatus, error) - ListCredentialsGroupsExecute(ctx context.Context, projectId string) (*objectstorage.ListCredentialsGroupsResponse, error) + EnableServiceExecute(ctx context.Context, projectId, region string) (*objectstorage.ProjectStatus, error) + ListCredentialsGroupsExecute(ctx context.Context, projectId, region string) (*objectstorage.ListCredentialsGroupsResponse, error) } // enableProject enables object storage for the specified project. If the project is already enabled, nothing happens -func enableProject(ctx context.Context, model *Model, client objectStorageClient) error { +func enableProject(ctx context.Context, model *Model, region string, client objectStorageClient) error { projectId := model.ProjectId.ValueString() // From the object storage OAS: Creation will also be successful if the project is already enabled, but will not create a duplicate - _, err := client.EnableServiceExecute(ctx, projectId) + _, err := client.EnableServiceExecute(ctx, projectId, region) if err != nil { return fmt.Errorf("failed to create object storage project: %w", err) } @@ -326,14 +382,14 @@ func enableProject(ctx context.Context, model *Model, client objectStorageClient // readCredentialsGroups gets all the existing credentials groups for the specified project, // finds the credentials group that is being read and updates the state. // Returns True if the credential was found, False otherwise. -func readCredentialsGroups(ctx context.Context, model *Model, client objectStorageClient) (bool, error) { +func readCredentialsGroups(ctx context.Context, model *Model, region string, client objectStorageClient) (bool, error) { found := false if model.CredentialsGroupId.ValueString() == "" && model.Name.ValueString() == "" { return found, fmt.Errorf("missing configuration: either name or credentials group id must be provided") } - credentialsGroupsResp, err := client.ListCredentialsGroupsExecute(ctx, model.ProjectId.ValueString()) + credentialsGroupsResp, err := client.ListCredentialsGroupsExecute(ctx, model.ProjectId.ValueString(), region) 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.StatusNotFound { diff --git a/stackit/internal/services/objectstorage/credentialsgroup/resource_test.go b/stackit/internal/services/objectstorage/credentialsgroup/resource_test.go index 3d718bf7..893bc801 100644 --- a/stackit/internal/services/objectstorage/credentialsgroup/resource_test.go +++ b/stackit/internal/services/objectstorage/credentialsgroup/resource_test.go @@ -16,7 +16,7 @@ type objectStorageClientMocked struct { listCredentialsGroupsResp *objectstorage.ListCredentialsGroupsResponse } -func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, projectId string) (*objectstorage.ProjectStatus, error) { +func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, projectId, _ string) (*objectstorage.ProjectStatus, error) { if c.returnError { return nil, fmt.Errorf("create project failed") } @@ -26,7 +26,7 @@ func (c *objectStorageClientMocked) EnableServiceExecute(_ context.Context, proj }, nil } -func (c *objectStorageClientMocked) ListCredentialsGroupsExecute(_ context.Context, _ string) (*objectstorage.ListCredentialsGroupsResponse, error) { +func (c *objectStorageClientMocked) ListCredentialsGroupsExecute(_ context.Context, _, _ string) (*objectstorage.ListCredentialsGroupsResponse, error) { if c.returnError { return nil, fmt.Errorf("get credentials groups failed") } @@ -52,6 +52,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), CredentialsGroupId: types.StringValue("cid"), URN: types.StringNull(), + Region: types.StringValue("eu01"), }, true, }, @@ -69,6 +70,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), CredentialsGroupId: types.StringValue("cid"), URN: types.StringValue("urn"), + Region: types.StringValue("eu01"), }, true, }, @@ -86,6 +88,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), CredentialsGroupId: types.StringValue("cid"), URN: types.StringValue(""), + Region: types.StringValue("eu01"), }, true, }, @@ -108,7 +111,7 @@ func TestMapFields(t *testing.T) { ProjectId: tt.expected.ProjectId, CredentialsGroupId: tt.expected.CredentialsGroupId, } - err := mapFields(tt.input, model) + err := mapFields(tt.input, model, "eu01") if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -147,7 +150,7 @@ func TestEnableProject(t *testing.T) { client := &objectStorageClientMocked{ returnError: tt.enableFails, } - err := enableProject(context.Background(), &Model{}, client) + err := enableProject(context.Background(), &Model{}, "eu01", client) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -288,7 +291,7 @@ func TestReadCredentialsGroups(t *testing.T) { ProjectId: tt.expectedModel.ProjectId, CredentialsGroupId: tt.expectedModel.CredentialsGroupId, } - found, err := readCredentialsGroups(context.Background(), model, client) + found, err := readCredentialsGroups(context.Background(), model, "eu01", client) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/objectstorage/objectstorage_acc_test.go b/stackit/internal/services/objectstorage/objectstorage_acc_test.go index 1c5c2b8a..8e831d4f 100644 --- a/stackit/internal/services/objectstorage/objectstorage_acc_test.go +++ b/stackit/internal/services/objectstorage/objectstorage_acc_test.go @@ -248,7 +248,7 @@ func testAccCheckObjectStorageDestroy(s *terraform.State) error { bucketsToDestroy = append(bucketsToDestroy, bucketName) } - bucketsResp, err := client.ListBuckets(ctx, testutil.ProjectId).Execute() + bucketsResp, err := client.ListBuckets(ctx, testutil.ProjectId, testutil.Region).Execute() if err != nil { return fmt.Errorf("getting bucketsResp: %w", err) } @@ -260,11 +260,11 @@ func testAccCheckObjectStorageDestroy(s *terraform.State) error { } bucketName := *bucket.Name if utils.Contains(bucketsToDestroy, bucketName) { - _, err := client.DeleteBucketExecute(ctx, testutil.ProjectId, bucketName) + _, err := client.DeleteBucketExecute(ctx, testutil.ProjectId, testutil.Region, bucketName) if err != nil { return fmt.Errorf("destroying bucket %s during CheckDestroy: %w", bucketName, err) } - _, err = wait.DeleteBucketWaitHandler(ctx, client, testutil.ProjectId, bucketName).WaitWithContext(ctx) + _, err = wait.DeleteBucketWaitHandler(ctx, client, testutil.ProjectId, testutil.Region, bucketName).WaitWithContext(ctx) if err != nil { return fmt.Errorf("destroying instance %s during CheckDestroy: waiting for deletion %w", bucketName, err) } @@ -281,7 +281,7 @@ func testAccCheckObjectStorageDestroy(s *terraform.State) error { credentialsGroupsToDestroy = append(credentialsGroupsToDestroy, credentialsGroupId) } - credentialsGroupsResp, err := client.ListCredentialsGroups(ctx, testutil.ProjectId).Execute() + credentialsGroupsResp, err := client.ListCredentialsGroups(ctx, testutil.ProjectId, testutil.Region).Execute() if err != nil { return fmt.Errorf("getting bucketsResp: %w", err) } @@ -293,7 +293,7 @@ func testAccCheckObjectStorageDestroy(s *terraform.State) error { } groupId := *group.CredentialsGroupId if utils.Contains(credentialsGroupsToDestroy, groupId) { - _, err := client.DeleteCredentialsGroupExecute(ctx, testutil.ProjectId, groupId) + _, err := client.DeleteCredentialsGroupExecute(ctx, testutil.ProjectId, testutil.Region, groupId) if err != nil { return fmt.Errorf("destroying credentials group %s during CheckDestroy: %w", groupId, err) } diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index a5ddf321..8cfee5e0 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -32,6 +32,7 @@ var ( OrganizationId = os.Getenv("TF_ACC_ORGANIZATION_ID") // ProjectId is the id of project used for tests ProjectId = os.Getenv("TF_ACC_PROJECT_ID") + Region = os.Getenv("TF_ACC_REGION") // ServerId is the id of a server used for some tests ServerId = getenv("TF_ACC_SERVER_ID", "") // IaaSImageId is the id of an image used for IaaS acceptance tests. Once the stackit_image resource is implemented, we can remove this diff --git a/stackit/internal/utils/regions.go b/stackit/internal/utils/regions.go new file mode 100644 index 00000000..554d9b0a --- /dev/null +++ b/stackit/internal/utils/regions.go @@ -0,0 +1,39 @@ +package utils + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" +) + +// AdaptRegion rewrites the region of a terraform plan +func AdaptRegion(ctx context.Context, configRegion types.String, planRegion *types.String, defaultRegion string, resp *resource.ModifyPlanResponse) { + // Get the intended region. This is either set directly set in the individual + // config or the provider region has to be used + var intendedRegion types.String + if configRegion.IsNull() { + if defaultRegion == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "set region", "no region defined in config or provider") + return + } + intendedRegion = types.StringValue(defaultRegion) + } else { + intendedRegion = configRegion + } + + // check if the currently configured region corresponds to the planned region + // on mismatch override the planned region with the intended region + // and force a replace of the resource + p := path.Root("region") + if !intendedRegion.Equal(*planRegion) { + resp.RequiresReplace.Append(p) + *planRegion = intendedRegion + } + resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, p, *planRegion)...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/stackit/internal/utils/regions_test.go b/stackit/internal/utils/regions_test.go new file mode 100644 index 00000000..78ca8db6 --- /dev/null +++ b/stackit/internal/utils/regions_test.go @@ -0,0 +1,87 @@ +package utils + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestAdaptRegion(t *testing.T) { + type model struct { + Region types.String `tfsdk:"region"` + } + type args struct { + configRegion types.String + defaultRegion string + } + testcases := []struct { + name string + args args + wantErr bool + wantRegion types.String + }{ + { + "no configured region, use provider region", + args{ + types.StringNull(), + "eu01", + }, + false, + types.StringValue("eu01"), + }, + { + "no configured region, no provider region => want error", + args{ + types.StringNull(), + "", + }, + true, + types.StringNull(), + }, + { + "configuration region overrides provider region", + args{ + types.StringValue("eu01-m"), + "eu01", + }, + false, + types.StringValue("eu01-m"), + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + plan := tfsdk.Plan{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "region": schema.StringAttribute{ + Required: true, + }, + }, + }, + } + + if diags := plan.Set(context.Background(), model{types.StringValue("unknown")}); diags.HasError() { + t.Fatalf("cannot create test model: %v", diags) + } + resp := resource.ModifyPlanResponse{ + Plan: plan, + } + + configModel := model{ + Region: tc.args.configRegion, + } + planModel := model{} + AdaptRegion(context.Background(), configModel.Region, &planModel.Region, tc.args.defaultRegion, &resp) + if diags := resp.Diagnostics; tc.wantErr != diags.HasError() { + t.Errorf("unexpected diagnostics: want err: %v, actual %v", tc.wantErr, diags.Errors()) + } + if expected, actual := tc.wantRegion, planModel.Region; !expected.Equal(actual) { + t.Errorf("wrong result region. expect %s but got %s", expected, actual) + } + }) + } +} diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index f4764659..4e9942b5 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -104,3 +104,13 @@ func QuoteValues(values []string) []string { func IsLegacyProjectRole(role string) bool { return utils.Contains(LegacyProjectRoles, role) } + +type value interface { + IsUnknown() bool + IsNull() bool +} + +// IsUndefined checks if a passed value is unknown or null +func IsUndefined(val value) bool { + return val.IsUnknown() || val.IsNull() +}