Feat/stackittpr 20 region adjustments | tfp (migrate first service to new regions concept) (#664)

* feat: completed bucket and credential group

* feat: fix linter warnings

* feat: updated documentation

* feat: updated to current version of the regional api

* feat: implement review findings

* feat: implement further review findings

* fix: make sure region is stored for the data-source in the state
This commit is contained in:
Rüdiger Schmitz 2025-02-10 14:28:33 +01:00 committed by GitHub
parent c4e25f560b
commit 2923621ab0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 503 additions and 104 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

2
go.mod
View file

@ -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

6
go.sum
View file

@ -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=

View file

@ -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

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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

View file

@ -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
}

View file

@ -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")
}

View file

@ -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...)

View file

@ -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 {

View file

@ -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")
}

View file

@ -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)
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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)
}
})
}
}

View file

@ -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()
}