feat: initial copy of v0.1.0
All checks were successful
Publish / Check GoReleaser config (push) Successful in 5s
Publish / Publish provider (push) Successful in 16m14s

This commit is contained in:
Marcel S. Henselin 2026-03-13 09:03:49 +01:00
parent 4cc801a7f3
commit 7d4cbb6b08
538 changed files with 63361 additions and 55213 deletions

View file

@ -11,7 +11,8 @@ import (
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
func ToString(ctx context.Context, v attr.Value) (string, error) {
@ -89,6 +90,16 @@ func StringValueToPointer(s basetypes.StringValue) *string {
return &value
}
// Int32ValueToPointer converts basetypes.Int64Value to a pointer to int64.
// It returns nil if the value is null or unknown.
func Int32ValueToPointer(s basetypes.Int32Value) *int32 {
if s.IsNull() || s.IsUnknown() {
return nil
}
value := s.ValueInt32()
return &value
}
// Int64ValueToPointer converts basetypes.Int64Value to a pointer to int64.
// It returns nil if the value is null or unknown.
func Int64ValueToPointer(s basetypes.Int64Value) *int64 {

View file

@ -8,7 +8,8 @@ import (
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"

View file

@ -32,7 +32,7 @@ const (
type EphemeralProviderData struct {
ProviderData
PrivateKey string
PrivateKey string //nolint:gosec //this is a placeholder and not used in this code
PrivateKeyPath string
ServiceAccountKey string
ServiceAccountKeyPath string
@ -105,11 +105,13 @@ func DiagsToError(diags diag.Diagnostics) error {
diagsError := diags.Errors()
diagsStrings := make([]string, 0)
for _, diagnostic := range diagsError {
diagsStrings = append(diagsStrings, fmt.Sprintf(
"(%s) %s",
diagnostic.Summary(),
diagnostic.Detail(),
))
diagsStrings = append(
diagsStrings, fmt.Sprintf(
"(%s) %s",
diagnostic.Summary(),
diagnostic.Detail(),
),
)
}
return fmt.Errorf("%s", strings.Join(diagsStrings, ";"))
}
@ -136,14 +138,22 @@ func LogAndAddWarning(ctx context.Context, diags *diag.Diagnostics, summary, det
func LogAndAddWarningBeta(ctx context.Context, diags *diag.Diagnostics, name string, resourceType ResourceType) {
warnTitle := fmt.Sprintf("The %s %q is in beta", resourceType, name)
warnContent := fmt.Sprintf("The %s %q is in beta and may be subject to breaking changes in the future. Use with caution.", resourceType, name)
warnContent := fmt.Sprintf(
"The %s %q is in beta and may be subject to breaking changes in the future. Use with caution.",
resourceType,
name,
)
tflog.Warn(ctx, fmt.Sprintf("%s | %s", warnTitle, warnContent))
diags.AddWarning(warnTitle, warnContent)
}
func LogAndAddErrorBeta(ctx context.Context, diags *diag.Diagnostics, name string, resourceType ResourceType) {
errTitle := fmt.Sprintf("The %s %q is in beta and beta is not enabled", resourceType, name)
errContent := fmt.Sprintf(`The %s %q is in beta and the beta functionality is currently not enabled. To enable it, set the environment variable STACKIT_TF_ENABLE_BETA_RESOURCES to "true" or set the "enable_beta_resources" provider field to true.`, resourceType, name)
errContent := fmt.Sprintf(
`The %s %q is in beta and the beta functionality is currently not enabled. To enable it, set the environment variable STACKIT_TF_ENABLE_BETA_RESOURCES to "true" or set the "enable_beta_resources" provider field to true.`,
resourceType,
name,
)
tflog.Error(ctx, fmt.Sprintf("%s | %s", errTitle, errContent))
diags.AddError(errTitle, errContent)
}
@ -161,8 +171,10 @@ func LogResponse(ctx context.Context) context.Context {
traceId := runtime.GetTraceId(ctx)
ctx = tflog.SetField(ctx, "x-trace-id", traceId)
tflog.Info(ctx, "response data", map[string]interface{}{
"x-trace-id": traceId,
})
tflog.Info(
ctx, "response data", map[string]interface{}{
"x-trace-id": traceId,
},
)
return ctx
}

View file

@ -0,0 +1,237 @@
package core
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"net/http"
"time"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
const (
// backoffMultiplier is the factor by which the delay is multiplied for exponential backoff.
backoffMultiplier = 2
// jitterFactor is the divisor used to calculate jitter (e.g., half of the base delay).
jitterFactor = 2
)
var (
// ErrRequestFailedAfterRetries is returned when a request fails after all retry attempts.
ErrRequestFailedAfterRetries = errors.New("request failed after all retry attempts")
)
// RetryRoundTripper implements an http.RoundTripper that adds automatic retry logic for failed requests.
type RetryRoundTripper struct {
next http.RoundTripper
maxRetries int
initialDelay time.Duration
maxDelay time.Duration
perTryTimeout time.Duration
}
// NewRetryRoundTripper creates a new instance of the RetryRoundTripper with the specified configuration.
func NewRetryRoundTripper(
next http.RoundTripper,
maxRetries int,
initialDelay, maxDelay, perTryTimeout time.Duration,
) *RetryRoundTripper {
return &RetryRoundTripper{
next: next,
maxRetries: maxRetries,
initialDelay: initialDelay,
maxDelay: maxDelay,
perTryTimeout: perTryTimeout,
}
}
// RoundTrip executes the request and retries on failure.
func (rrt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := rrt.executeRequest(req)
if !rrt.shouldRetry(resp, err) {
if err != nil {
return resp, fmt.Errorf("initial request failed, not retrying: %w", err)
}
return resp, nil
}
return rrt.retryLoop(req, resp, err)
}
// executeRequest performs a single HTTP request with a per-try timeout.
func (rrt *RetryRoundTripper) executeRequest(req *http.Request) (*http.Response, error) {
ctx, cancel := context.WithTimeout(req.Context(), rrt.perTryTimeout)
defer cancel()
resp, err := rrt.next.RoundTrip(req.WithContext(ctx))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return resp, fmt.Errorf("per-try timeout of %v exceeded: %w", rrt.perTryTimeout, err)
}
return resp, fmt.Errorf("http roundtrip failed: %w", err)
}
return resp, nil
}
// retryLoop handles the retry logic for a failed request.
func (rrt *RetryRoundTripper) retryLoop(
req *http.Request,
initialResp *http.Response,
initialErr error,
) (*http.Response, error) {
var (
lastErr = initialErr
resp = initialResp
currentDelay = rrt.initialDelay
)
ctx := req.Context()
for attempt := 1; attempt <= rrt.maxRetries; attempt++ {
rrt.logRetryAttempt(ctx, attempt, currentDelay, lastErr)
waitDuration := rrt.calculateWaitDurationWithJitter(ctx, currentDelay)
if err := rrt.waitForDelay(ctx, waitDuration); err != nil {
return nil, err // Context was canceled during wait.
}
// Exponential backoff for the next potential retry.
currentDelay = rrt.updateCurrentDelay(currentDelay)
// Retry attempt.
resp, lastErr = rrt.executeRequest(req)
if !rrt.shouldRetry(resp, lastErr) {
if lastErr != nil {
return resp, fmt.Errorf("request failed on retry attempt %d: %w", attempt, lastErr)
}
return resp, nil
}
}
return nil, rrt.handleFinalError(ctx, resp, lastErr)
}
// logRetryAttempt logs the details of a retry attempt.
func (rrt *RetryRoundTripper) logRetryAttempt(
ctx context.Context,
attempt int,
delay time.Duration,
err error,
) {
tflog.Info(
ctx, "Request failed, retrying...", map[string]interface{}{
"attempt": attempt,
"max_attempts": rrt.maxRetries,
"delay": delay,
"error": err,
},
)
}
// updateCurrentDelay calculates the next delay for exponential backoff.
func (rrt *RetryRoundTripper) updateCurrentDelay(currentDelay time.Duration) time.Duration {
currentDelay *= backoffMultiplier
if currentDelay > rrt.maxDelay {
return rrt.maxDelay
}
return currentDelay
}
// handleFinalError constructs and returns the final error after all retries have been exhausted.
func (rrt *RetryRoundTripper) handleFinalError(
ctx context.Context,
resp *http.Response,
lastErr error,
) error {
if resp != nil {
if err := resp.Body.Close(); err != nil {
tflog.Warn(
ctx, "Failed to close response body", map[string]interface{}{
"error": err.Error(),
},
)
}
}
if lastErr != nil {
return fmt.Errorf("%w: %w", ErrRequestFailedAfterRetries, lastErr)
}
// This case occurs if shouldRetry was true due to a retryable status code,
// but all retries failed with similar status codes.
if resp != nil {
return fmt.Errorf(
"%w: last retry attempt failed with status code %d",
ErrRequestFailedAfterRetries,
resp.StatusCode,
)
}
return fmt.Errorf("%w: no response received", ErrRequestFailedAfterRetries)
}
// shouldRetry determines if a request should be retried based on the response or an error.
func (rrt *RetryRoundTripper) shouldRetry(resp *http.Response, err error) bool {
if err != nil {
return true
}
if resp != nil {
if resp.StatusCode == http.StatusBadGateway ||
resp.StatusCode == http.StatusServiceUnavailable ||
resp.StatusCode == http.StatusGatewayTimeout {
return true
}
}
return false
}
// calculateWaitDurationWithJitter calculates the backoff duration for the next retry,
// adding a random jitter to prevent thundering herd issues.
func (rrt *RetryRoundTripper) calculateWaitDurationWithJitter(
ctx context.Context,
baseDelay time.Duration,
) time.Duration {
if baseDelay <= 0 {
return 0
}
maxJitter := int64(baseDelay / jitterFactor)
if maxJitter <= 0 {
return baseDelay
}
random, err := rand.Int(rand.Reader, big.NewInt(maxJitter))
if err != nil {
tflog.Warn(
ctx, "Failed to generate random jitter, proceeding without it.", map[string]interface{}{
"error": err.Error(),
},
)
return baseDelay
}
jitter := time.Duration(random.Int64())
return baseDelay + jitter
}
// waitForDelay pauses execution for a given duration or until the context is canceled.
func (rrt *RetryRoundTripper) waitForDelay(ctx context.Context, delay time.Duration) error {
select {
case <-ctx.Done():
return fmt.Errorf("context canceled during backoff wait: %w", ctx.Err())
case <-time.After(delay):
return nil
}
}

View file

@ -0,0 +1,252 @@
package core
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
type mockRoundTripper struct {
roundTripFunc func(req *http.Request) (*http.Response, error)
callCount int32
}
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
atomic.AddInt32(&m.callCount, 1)
return m.roundTripFunc(req)
}
func (m *mockRoundTripper) CallCount() int32 {
return atomic.LoadInt32(&m.callCount)
}
func TestRetryRoundTripper_RoundTrip(t *testing.T) {
t.Parallel()
testRetryConfig := func(next http.RoundTripper) *RetryRoundTripper {
return NewRetryRoundTripper(
next,
3,
1*time.Millisecond,
10*time.Millisecond,
50*time.Millisecond,
)
}
noRetryTests := []struct {
name string
mockStatusCode int
expectedStatusCode int
}{
{
name: "should succeed on the first try",
mockStatusCode: http.StatusOK,
expectedStatusCode: http.StatusOK,
},
{
name: "should not retry on a non-retryable status code like 400",
mockStatusCode: http.StatusBadRequest,
expectedStatusCode: http.StatusBadRequest,
},
}
for _, testCase := range noRetryTests {
t.Run(
testCase.name, func(t *testing.T) {
t.Parallel()
mock := &mockRoundTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: testCase.mockStatusCode,
Body: io.NopCloser(nil),
Request: req,
}, nil
},
}
tripper := testRetryConfig(mock)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
resp, err := tripper.RoundTrip(req)
if resp != nil {
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
t.Errorf("failed to close response body: %v", closeErr)
}
}()
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.StatusCode != testCase.expectedStatusCode {
t.Fatalf("expected status code %d, got %d", testCase.expectedStatusCode, resp.StatusCode)
}
if mock.CallCount() != 1 {
t.Fatalf("expected 1 call, got %d", mock.CallCount())
}
},
)
}
t.Run(
"should retry on retryable status code (503) and eventually fail", func(t *testing.T) {
t.Parallel()
mock := &mockRoundTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Body: io.NopCloser(nil),
Request: req,
}, nil
},
}
tripper := testRetryConfig(mock)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
resp, err := tripper.RoundTrip(req)
if resp != nil {
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
t.Errorf("failed to close response body: %v", closeErr)
}
}()
}
if err == nil {
t.Fatal("expected an error, but got nil")
}
expectedErrorMsg := "last retry attempt failed with status code 503"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Fatalf("expected error to contain %q, got %q", expectedErrorMsg, err.Error())
}
if mock.CallCount() != 4 { // 1 initial + 3 retries
t.Fatalf("expected 4 calls, got %d", mock.CallCount())
}
},
)
t.Run(
"should succeed after one retry", func(t *testing.T) {
t.Parallel()
mock := &mockRoundTripper{}
mock.roundTripFunc = func(req *http.Request) (*http.Response, error) {
if mock.CallCount() < 2 {
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Body: io.NopCloser(nil),
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(nil),
Request: req,
}, nil
}
tripper := testRetryConfig(mock)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
resp, err := tripper.RoundTrip(req)
if resp != nil {
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
t.Errorf("failed to close response body: %v", closeErr)
}
}()
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status code %d, got %d", http.StatusOK, resp.StatusCode)
}
if mock.CallCount() != 2 {
t.Fatalf("expected 2 calls, got %d", mock.CallCount())
}
},
)
t.Run(
"should retry on network error", func(t *testing.T) {
t.Parallel()
mockErr := errors.New("simulated network error")
mock := &mockRoundTripper{
roundTripFunc: func(_ *http.Request) (*http.Response, error) {
return nil, mockErr
},
}
tripper := testRetryConfig(mock)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
resp, err := tripper.RoundTrip(req)
if resp != nil {
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
t.Errorf("failed to close response body: %v", closeErr)
}
}()
}
if !errors.Is(err, mockErr) {
t.Fatalf("expected error to be %v, got %v", mockErr, err)
}
if mock.CallCount() != 4 { // 1 initial + 3 retries
t.Fatalf("expected 4 calls, got %d", mock.CallCount())
}
},
)
t.Run(
"should abort retries if the main context is canceled", func(t *testing.T) {
t.Parallel()
mock := &mockRoundTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
select {
case <-time.After(100 * time.Millisecond):
return nil, errors.New("this should not be returned")
case <-req.Context().Done():
return nil, req.Context().Err()
}
},
}
tripper := testRetryConfig(mock)
baseCtx := context.Background()
ctx, cancel := context.WithTimeout(baseCtx, 20*time.Millisecond)
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody).WithContext(ctx)
resp, err := tripper.RoundTrip(req)
if resp != nil {
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
t.Errorf("failed to close response body: %v", closeErr)
}
}()
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected error to be context.DeadlineExceeded, got %v", err)
}
if mock.CallCount() != 1 {
t.Fatalf("expected 1 call, got %d", mock.CallCount())
}
},
)
}

View file

@ -9,7 +9,8 @@ import (
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
// BetaResourcesEnabled returns whether this provider has beta functionality enabled.

View file

@ -7,7 +7,8 @@ import (
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
func TestBetaResourcesEnabled(t *testing.T) {

View file

@ -10,7 +10,8 @@ import (
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
const (

View file

@ -7,7 +7,8 @@ import (
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
func TestValidExperiment(t *testing.T) {

View file

@ -0,0 +1,196 @@
package postgresflexalpha
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
pgDsGen "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/database/datasources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &databaseDataSource{}
)
// NewDatabaseDataSource is a helper function to simplify the provider implementation.
func NewDatabaseDataSource() datasource.DataSource {
return &databaseDataSource{}
}
// dataSourceModel maps the data source schema data.
type dataSourceModel struct {
pgDsGen.DatabaseModel
TerraformID types.String `tfsdk:"id"`
}
// databaseDataSource is the data source implementation.
type databaseDataSource struct {
client *v3alpha1api.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (r *databaseDataSource) Metadata(
_ context.Context,
req datasource.MetadataRequest,
resp *datasource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_database"
}
// Configure adds the provider configured client to the data source.
func (r *databaseDataSource) Configure(
ctx context.Context,
req datasource.ConfigureRequest,
resp *datasource.ConfigureResponse,
) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex database client configured")
}
// Schema defines the schema for the data source.
func (r *databaseDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
s := pgDsGen.DatabaseDataSourceSchema(ctx)
s.Attributes["id"] = schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \\\"`project_id`,`region`,`instance_id`," +
"`database_id`\\\".\",",
Computed: true,
}
resp.Schema = s
}
// Read fetches the data for the data source.
func (r *databaseDataSource) Read(
ctx context.Context,
req datasource.ReadRequest,
resp *datasource.ReadResponse,
) { // nolint:gocritic // function signature required by Terraform
var model dataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
databaseResp, err := r.getDatabaseByNameOrID(ctx, &model, projectId, region, instanceId, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
if err != nil {
handleReadError(ctx, &resp.Diagnostics, err, projectId, instanceId)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema and populate Computed attribute values
err = mapFields(databaseResp, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database read")
}
// getDatabaseByNameOrID retrieves a single database by ensuring either a unique ID or name is provided.
func (r *databaseDataSource) getDatabaseByNameOrID(
ctx context.Context,
model *dataSourceModel,
projectId, region, instanceId string,
diags *diag.Diagnostics,
) (*v3alpha1api.ListDatabase, error) {
isIdSet := !model.DatabaseId.IsNull() && !model.DatabaseId.IsUnknown()
isNameSet := !model.Name.IsNull() && !model.Name.IsUnknown()
if (isIdSet && isNameSet) || (!isIdSet && !isNameSet) {
diags.AddError(
"Invalid configuration",
"Exactly one of 'id' or 'name' must be specified.",
)
return nil, nil
}
if isIdSet {
databaseId := model.DatabaseId.ValueInt64()
ctx = tflog.SetField(ctx, "database_id", databaseId)
return getDatabaseById(ctx, r.client.DefaultAPI, projectId, region, instanceId, databaseId)
}
databaseName := model.Name.ValueString()
ctx = tflog.SetField(ctx, "name", databaseName)
return getDatabaseByName(ctx, r.client.DefaultAPI, projectId, region, instanceId, databaseName)
}
// handleReadError centralizes API error handling for the Read operation.
func handleReadError(ctx context.Context, diags *diag.Diagnostics, err error, projectId, instanceId string) {
utils.LogError(
ctx,
diags,
err,
"Reading database",
fmt.Sprintf(
"Could not retrieve database for instance %q in project %q.",
instanceId,
projectId,
),
map[int]string{
http.StatusBadRequest: fmt.Sprintf(
"Invalid request parameters for project %q and instance %q.",
projectId,
instanceId,
),
http.StatusNotFound: fmt.Sprintf(
"Database, instance %q, or project %q not found.",
instanceId,
projectId,
),
http.StatusForbidden: fmt.Sprintf("Forbidden access to project %q.", projectId),
},
)
}

View file

@ -1,171 +0,0 @@
package postgresflexa
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflexalpha/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"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"
"github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &databaseDataSource{}
)
// NewDatabaseDataSource is a helper function to simplify the provider implementation.
func NewDatabaseDataSource() datasource.DataSource {
return &databaseDataSource{}
}
// databaseDataSource is the data source implementation.
type databaseDataSource struct {
client *postgresflexalpha.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (r *databaseDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_database"
}
// Configure adds the provider configured client to the data source.
func (r *databaseDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex database client configured")
}
// Schema defines the schema for the data source.
func (r *databaseDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex database resource schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`,`database_id`\".",
"database_id": "Database ID.",
"instance_id": "ID of the Postgres Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Database name.",
"owner": "Username of the database owner.",
"region": "The resource region. If not defined, the provider region is used.",
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"database_id": schema.StringAttribute{
Description: descriptions["database_id"],
Required: true,
Validators: []validator.String{
validate.NoSeparator(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Computed: true,
},
"owner": schema.StringAttribute{
Description: descriptions["owner"],
Computed: true,
},
"region": schema.StringAttribute{
// the region cannot be found, so it has to be passed
Optional: true,
Description: descriptions["region"],
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (r *databaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
databaseId := model.DatabaseId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
databaseResp, err := getDatabase(ctx, r.client, projectId, region, instanceId, databaseId)
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading database",
fmt.Sprintf("Database with ID %q or instance with ID %q does not exist in project %q.", databaseId, instanceId, projectId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema and populate Computed attribute values
err = mapFields(databaseResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database read")
}

View file

@ -0,0 +1,69 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package postgresflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
)
func DatabaseDataSourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"database_id": schema.Int64Attribute{
Required: true,
Description: "The ID of the database.",
MarkdownDescription: "The ID of the database.",
},
"tf_original_api_id": schema.Int64Attribute{
Computed: true,
Description: "The id of the database.",
MarkdownDescription: "The id of the database.",
},
"instance_id": schema.StringAttribute{
Required: true,
Description: "The ID of the instance.",
MarkdownDescription: "The ID of the instance.",
},
"name": schema.StringAttribute{
Computed: true,
Description: "The name of the database.",
MarkdownDescription: "The name of the database.",
},
"owner": schema.StringAttribute{
Computed: true,
Description: "The owner of the database.",
MarkdownDescription: "The owner of the database.",
},
"project_id": schema.StringAttribute{
Required: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Required: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
},
}
}
type DatabaseModel struct {
DatabaseId types.Int64 `tfsdk:"database_id"`
Id types.Int64 `tfsdk:"tf_original_api_id"`
InstanceId types.String `tfsdk:"instance_id"`
Name types.String `tfsdk:"name"`
Owner types.String `tfsdk:"owner"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
}

View file

@ -0,0 +1,87 @@
package postgresflexalpha
import (
"context"
"fmt"
"strings"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
)
// databaseClientReader represents the contract to listing databases from postgresflex.APIClient.
type databaseClientReader interface {
ListDatabasesRequest(
ctx context.Context,
projectId string,
region string,
instanceId string,
) v3alpha1api.ApiListDatabasesRequestRequest
}
// getDatabaseById gets a database by its ID.
func getDatabaseById(
ctx context.Context,
client databaseClientReader,
projectId, region, instanceId string,
databaseId int64,
) (*v3alpha1api.ListDatabase, error) {
filter := func(db v3alpha1api.ListDatabase) bool {
return int64(db.Id) == databaseId
}
return getDatabase(ctx, client, projectId, region, instanceId, filter)
}
// getDatabaseByName gets a database by its name.
func getDatabaseByName(
ctx context.Context,
client databaseClientReader,
projectId, region, instanceId, databaseName string,
) (*v3alpha1api.ListDatabase, error) {
filter := func(db v3alpha1api.ListDatabase) bool {
return db.Name == databaseName
}
return getDatabase(ctx, client, projectId, region, instanceId, filter)
}
// getDatabase is a helper function to retrieve a database using a filter function.
// Hint: The API does not have a GetDatabase endpoint, only ListDatabases
func getDatabase(
ctx context.Context,
client databaseClientReader,
projectId, region, instanceId string,
filter func(db v3alpha1api.ListDatabase) bool,
) (*v3alpha1api.ListDatabase, error) {
if projectId == "" || region == "" || instanceId == "" {
return nil, fmt.Errorf("all parameters (project, region, instance) are required")
}
const pageSize = 25
for page := int32(1); ; page++ {
res, err := client.ListDatabasesRequest(ctx, projectId, region, instanceId).
Page(page).Size(pageSize).Sort(v3alpha1api.DATABASESORT_DATABASE_ID_ASC).Execute()
if err != nil {
return nil, fmt.Errorf("requesting database list (page %d): %w", page, err)
}
// If the API returns no databases, we have reached the end of the list.
if len(res.Databases) == 0 {
break
}
// Iterate over databases to find a match
for _, db := range res.Databases {
if filter(db) {
foundDb := db
return &foundDb, nil
}
}
}
return nil, fmt.Errorf("database not found for instance %s", instanceId)
}
// cleanString removes leading and trailing quotes which are sometimes returned by the API.
func cleanString(s string) string {
return strings.Trim(s, "\"")
}

View file

@ -0,0 +1,199 @@
package postgresflexalpha
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
)
func TestGetDatabase(t *testing.T) {
mockResp := func(page int32) (*v3alpha1api.ListDatabasesResponse, error) {
if page == 1 {
return &v3alpha1api.ListDatabasesResponse{
Databases: []v3alpha1api.ListDatabase{
{Id: int32(1), Name: "first"},
{Id: int32(2), Name: "second"},
},
Pagination: v3alpha1api.Pagination{
Page: int32(1),
TotalPages: int32(2),
Size: int32(3),
},
}, nil
}
if page == 2 {
return &v3alpha1api.ListDatabasesResponse{
Databases: []v3alpha1api.ListDatabase{{Id: int32(3), Name: "three"}},
Pagination: v3alpha1api.Pagination{
Page: int32(2),
TotalPages: int32(2),
Size: int32(3),
},
}, nil
}
return &v3alpha1api.ListDatabasesResponse{
Databases: []v3alpha1api.ListDatabase{},
Pagination: v3alpha1api.Pagination{
Page: int32(3),
TotalPages: int32(2),
Size: int32(3),
},
}, nil
}
tests := []struct {
description string
projectID string
region string
instanceID string
wantErr bool
wantDbName string
wantDbID int32
}{
{
description: "Success - Found by name on first page",
projectID: "pid", region: "reg", instanceID: "inst",
wantErr: false,
wantDbName: "second",
},
{
description: "Success - Found by id on first page",
projectID: "pid", region: "reg", instanceID: "inst",
wantErr: false,
wantDbID: 2,
},
{
description: "Success - Found by name on second page",
projectID: "pid", region: "reg", instanceID: "inst",
wantErr: false,
wantDbName: "three",
},
{
description: "Success - Found by id on second page",
projectID: "pid", region: "reg", instanceID: "inst",
wantErr: false,
wantDbID: 1,
},
{
description: "Error - API failure",
projectID: "pid", region: "reg", instanceID: "inst",
wantErr: true,
},
{
description: "Error - Missing parameters",
projectID: "", region: "reg", instanceID: "inst",
wantErr: true,
},
{
description: "Error - Search by name not found after all pages",
projectID: "pid", region: "reg", instanceID: "inst",
wantDbName: "non-existent",
wantErr: true,
},
{
description: "Error - Search by id not found after all pages",
projectID: "pid", region: "reg", instanceID: "inst",
wantDbID: 999999,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
var currentPage int32
mockCall := func(_ v3alpha1api.ApiListDatabasesRequestRequest) (*v3alpha1api.ListDatabasesResponse, error) {
currentPage++
return mockResp(currentPage)
}
client := &v3alpha1api.DefaultAPIServiceMock{
ListDatabasesRequestExecuteMock: &mockCall,
}
var actual *v3alpha1api.ListDatabase
var errDB error
if tt.wantDbName != "" {
actual, errDB = getDatabaseByName(
t.Context(),
client,
tt.projectID,
tt.region,
tt.instanceID,
tt.wantDbName,
)
} else if tt.wantDbID != 0 {
actual, errDB = getDatabaseById(
t.Context(),
client,
tt.projectID,
tt.region,
tt.instanceID,
int64(tt.wantDbID),
)
} else {
actual, errDB = getDatabase(
context.Background(),
client,
tt.projectID,
tt.region,
tt.instanceID,
func(_ v3alpha1api.ListDatabase) bool { return false },
)
}
if (errDB != nil) != tt.wantErr {
t.Errorf("getDatabaseByNameOrID() error = %v, wantErr %v", errDB, tt.wantErr)
return
}
if !tt.wantErr && tt.wantDbName != "" && actual != nil {
if actual.Name != tt.wantDbName {
t.Errorf("getDatabaseByNameOrID() got name = %v, want %v", actual.Name, tt.wantDbName)
}
}
if !tt.wantErr && tt.wantDbID != 0 && actual != nil {
if actual.Id != tt.wantDbID {
t.Errorf("getDatabaseByNameOrID() got id = %v, want %v", actual.Id, tt.wantDbID)
}
}
},
)
}
}
func TestCleanString(t *testing.T) {
testcases := []struct {
name string
given string
expected string
}{
{
name: "should remove quotes",
given: "\"quoted\"",
expected: "quoted",
},
{
name: "should not change unquoted string",
given: "unquoted",
expected: "unquoted",
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
actual := cleanString(tc.given)
if diff := cmp.Diff(tc.expected, actual); diff != "" {
t.Errorf("string mismatch (-want +got):\n%s", diff)
}
},
)
}
}

View file

@ -0,0 +1,93 @@
package postgresflexalpha
import (
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
// mapFields maps fields from a ListDatabase API response to a resourceModel for the data source.
func mapFields(
source *v3alpha1api.ListDatabase,
model *dataSourceModel,
region string,
) error {
if source == nil {
return fmt.Errorf("response is nil")
}
if source.Id == 0 {
return fmt.Errorf("id not present")
}
if model == nil {
return fmt.Errorf("model given is nil")
}
var databaseId int64
if model.DatabaseId.ValueInt64() != 0 {
databaseId = model.DatabaseId.ValueInt64()
} else if source.Id != 0 {
databaseId = int64(source.Id)
} else {
return fmt.Errorf("database id not present")
}
model.Id = types.Int64Value(databaseId)
model.DatabaseId = types.Int64Value(databaseId)
model.Name = types.StringValue(source.GetName())
model.Owner = types.StringValue(cleanString(source.Owner))
model.Region = types.StringValue(region)
model.ProjectId = types.StringValue(model.ProjectId.ValueString())
model.InstanceId = types.StringValue(model.InstanceId.ValueString())
model.TerraformID = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(),
region,
model.InstanceId.ValueString(),
strconv.FormatInt(databaseId, 10),
)
return nil
}
// mapResourceFields maps fields from a GetDatabase API response to a resourceModel for the resource.
func mapResourceFields(source *v3alpha1api.GetDatabaseResponse, model *resourceModel) error {
if source == nil {
return fmt.Errorf("response is nil")
}
if source.Id == 0 {
return fmt.Errorf("id not present")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var databaseId int64
if model.Id.ValueInt64() != 0 {
databaseId = model.Id.ValueInt64()
} else if source.Id != 0 {
databaseId = int64(source.Id)
} else {
return fmt.Errorf("database id not present")
}
model.Id = types.Int64Value(databaseId)
model.DatabaseId = types.Int64Value(databaseId)
model.Name = types.StringValue(source.GetName())
model.Owner = types.StringValue(cleanString(source.Owner))
return nil
}
// toCreatePayload converts the resource model to an API create payload.
func toCreatePayload(model *resourceModel) (*v3alpha1api.CreateDatabaseRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &v3alpha1api.CreateDatabaseRequestPayload{
Name: model.Name.ValueString(),
Owner: model.Owner.ValueStringPointer(),
}, nil
}

View file

@ -0,0 +1,248 @@
package postgresflexalpha
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
postgresflexalpha "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
datasource "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/database/datasources_gen"
)
func TestMapFields(t *testing.T) {
type given struct {
source *postgresflexalpha.ListDatabase
model *dataSourceModel
region string
}
type expected struct {
model *dataSourceModel
err bool
}
testcases := []struct {
name string
given given
expected expected
}{
{
name: "should map fields correctly",
given: given{
source: &postgresflexalpha.ListDatabase{
Id: int32(1),
Name: "my-db",
Owner: "my-owner",
},
model: &dataSourceModel{
DatabaseModel: datasource.DatabaseModel{
ProjectId: types.StringValue("my-project"),
InstanceId: types.StringValue("my-instance"),
},
},
region: "eu01",
},
expected: expected{
model: &dataSourceModel{
DatabaseModel: datasource.DatabaseModel{
Id: types.Int64Value(1),
Name: types.StringValue("my-db"),
Owner: types.StringValue("my-owner"),
Region: types.StringValue("eu01"),
DatabaseId: types.Int64Value(1),
InstanceId: types.StringValue("my-instance"),
ProjectId: types.StringValue("my-project"),
},
TerraformID: types.StringValue("my-project,eu01,my-instance,1"),
},
},
},
{
name: "should preserve existing model ID",
given: given{
source: &postgresflexalpha.ListDatabase{
Id: int32(1),
Name: "my-db",
},
model: &dataSourceModel{
DatabaseModel: datasource.DatabaseModel{
Id: types.Int64Value(1),
ProjectId: types.StringValue("my-project"),
InstanceId: types.StringValue("my-instance"),
},
},
region: "eu01",
},
expected: expected{
model: &dataSourceModel{
DatabaseModel: datasource.DatabaseModel{
Id: types.Int64Value(1),
Name: types.StringValue("my-db"),
Owner: types.StringValue(""),
DatabaseId: types.Int64Value(1),
Region: types.StringValue("eu01"),
InstanceId: types.StringValue("my-instance"),
ProjectId: types.StringValue("my-project"),
},
TerraformID: types.StringValue("my-project,eu01,my-instance,1"),
},
},
},
{
name: "should fail on nil source",
given: given{
source: nil,
model: &dataSourceModel{},
},
expected: expected{err: true},
},
{
name: "should fail on nil source ID",
given: given{
source: &postgresflexalpha.ListDatabase{Id: 0},
model: &dataSourceModel{},
},
expected: expected{err: true},
},
{
name: "should fail on nil model",
given: given{
source: &postgresflexalpha.ListDatabase{Id: int32(1)},
model: nil,
},
expected: expected{err: true},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
err := mapFields(tc.given.source, tc.given.model, tc.given.region)
if (err != nil) != tc.expected.err {
t.Fatalf("expected error: %v, got: %v", tc.expected.err, err)
}
if err == nil {
if diff := cmp.Diff(tc.expected.model, tc.given.model); diff != "" {
t.Errorf("model mismatch (-want +got):\n%s", diff)
}
}
},
)
}
}
func TestMapResourceFields(t *testing.T) {
type given struct {
source *postgresflexalpha.GetDatabaseResponse
model *resourceModel
}
type expected struct {
model *resourceModel
err bool
}
testcases := []struct {
name string
given given
expected expected
}{
{
name: "should map fields correctly",
given: given{
source: &postgresflexalpha.GetDatabaseResponse{
Id: int32(1),
Name: "my-db",
Owner: "my-owner",
},
model: &resourceModel{},
},
expected: expected{
model: &resourceModel{
Id: types.Int64Value(1),
Name: types.StringValue("my-db"),
Owner: types.StringValue("my-owner"),
DatabaseId: types.Int64Value(1),
},
},
},
{
name: "should fail on nil source",
given: given{
source: nil,
model: &resourceModel{},
},
expected: expected{err: true},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
err := mapResourceFields(tc.given.source, tc.given.model)
if (err != nil) != tc.expected.err {
t.Fatalf("expected error: %v, got: %v", tc.expected.err, err)
}
if err == nil {
if diff := cmp.Diff(tc.expected.model, tc.given.model); diff != "" {
t.Errorf("model mismatch (-want +got):\n%s", diff)
}
}
},
)
}
}
func TestToCreatePayload(t *testing.T) {
type given struct {
model *resourceModel
}
type expected struct {
payload *postgresflexalpha.CreateDatabaseRequestPayload
err bool
}
testcases := []struct {
name string
given given
expected expected
}{
{
name: "should convert model to payload",
given: given{
model: &resourceModel{
Name: types.StringValue("my-db"),
Owner: types.StringValue("my-owner"),
},
},
expected: expected{
payload: &postgresflexalpha.CreateDatabaseRequestPayload{
Name: "my-db",
Owner: utils.Ptr("my-owner"),
},
},
},
{
name: "should fail on nil model",
given: given{model: nil},
expected: expected{err: true},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
actual, err := toCreatePayload(tc.given.model)
if (err != nil) != tc.expected.err {
t.Fatalf("expected error: %v, got: %v", tc.expected.err, err)
}
if err == nil {
if diff := cmp.Diff(tc.expected.payload, actual); diff != "" {
t.Errorf("payload mismatch (-want +got):\n%s", diff)
}
}
},
)
}
}

View file

@ -0,0 +1,35 @@
fields:
- name: 'id'
modifiers:
- 'UseStateForUnknown'
- name: 'database_id'
modifiers:
- 'UseStateForUnknown'
validators:
- validate.NoSeparator
- validate.UUID
- name: 'instance_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'
- name: 'project_id'
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'
validators:
- validate.NoSeparator
- validate.UUID
- name: 'name'
validators:
- validate.NoSeparator
- name: 'region'
modifiers:
- 'RequiresReplace'

View file

@ -0,0 +1,625 @@
package postgresflexalpha
import (
"context"
_ "embed"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/identityschema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
postgresflexalphaResGen "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/database/resources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
postgresflexalphaWait "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/wait/postgresflexalpha"
)
var (
// Ensure the implementation satisfies the expected interfaces.
_ resource.Resource = &databaseResource{}
_ resource.ResourceWithConfigure = &databaseResource{}
_ resource.ResourceWithImportState = &databaseResource{}
_ resource.ResourceWithModifyPlan = &databaseResource{}
_ resource.ResourceWithIdentity = &databaseResource{}
// Error message constants
extractErrorSummary = "extracting failed"
extractErrorMessage = "Extracting identity data: %v"
)
// NewDatabaseResource is a helper function to simplify the provider implementation.
func NewDatabaseResource() resource.Resource {
return &databaseResource{}
}
// resourceModel describes the resource data model.
type resourceModel = postgresflexalphaResGen.DatabaseModel
// DatabaseResourceIdentityModel describes the resource's identity attributes.
type DatabaseResourceIdentityModel struct {
ProjectID types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
InstanceID types.String `tfsdk:"instance_id"`
DatabaseID types.Int64 `tfsdk:"database_id"`
}
// databaseResource is the resource implementation.
type databaseResource struct {
client *v3alpha1api.APIClient
providerData core.ProviderData
}
// ModifyPlan adjusts the plan to set the correct region.
func (r *databaseResource) ModifyPlan(
ctx context.Context,
req resource.ModifyPlanRequest,
resp *resource.ModifyPlanResponse,
) { // nolint:gocritic // function signature required by Terraform
var configModel resourceModel
// 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 resourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), 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.
func (r *databaseResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_database"
}
// Configure adds the provider configured client to the resource.
func (r *databaseResource) Configure(
ctx context.Context,
req resource.ConfigureRequest,
resp *resource.ConfigureResponse,
) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex database client configured")
}
//go:embed planModifiers.yaml
var modifiersFileByte []byte
// Schema defines the schema for the resource.
func (r *databaseResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
s := postgresflexalphaResGen.DatabaseResourceSchema(ctx)
fields, err := utils.ReadModifiersConfig(modifiersFileByte)
if err != nil {
resp.Diagnostics.AddError("error during read modifiers config file", err.Error())
return
}
err = utils.AddPlanModifiersToResourceSchema(fields, &s)
if err != nil {
resp.Diagnostics.AddError("error adding plan modifiers", err.Error())
return
}
resp.Schema = s
}
// IdentitySchema defines the schema for the resource's identity attributes.
func (r *databaseResource) IdentitySchema(
_ context.Context,
_ resource.IdentitySchemaRequest,
response *resource.IdentitySchemaResponse,
) {
response.IdentitySchema = identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"project_id": identityschema.StringAttribute{
RequiredForImport: true,
},
"region": identityschema.StringAttribute{
RequiredForImport: true,
},
"instance_id": identityschema.StringAttribute{
RequiredForImport: true,
},
"database_id": identityschema.Int64Attribute{
RequiredForImport: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *databaseResource) Create(
ctx context.Context,
req resource.CreateRequest,
resp *resource.CreateResponse,
) { // nolint:gocritic // function signature required by Terraform
const funcErrorSummary = "[database CREATE] error"
var model resourceModel
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
instanceId := model.InstanceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
funcErrorSummary,
fmt.Sprintf("Creating API payload: %v", err),
)
return
}
// Create new database
databaseResp, err := r.client.DefaultAPI.CreateDatabaseRequest(
ctx,
projectId,
region,
instanceId,
).CreateDatabaseRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, funcErrorSummary, fmt.Sprintf("Calling API: %v", err))
return
}
dbID, ok := databaseResp.GetIdOk()
if !ok {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
funcErrorSummary,
"API didn't return database Id. A database might although have been created",
)
return
}
databaseId := int64(*dbID)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = core.LogResponse(ctx)
// Save identity into Terraform state
identity := DatabaseResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
DatabaseID: types.Int64Value(databaseId),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
database, err := postgresflexalphaWait.GetDatabaseByIdWaitHandler(ctx, r.client.DefaultAPI, projectId, instanceId, region, databaseId).
SetTimeout(15 * time.Minute).
SetSleepBeforeWait(15 * time.Second).
WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
funcErrorSummary,
fmt.Sprintf("Getting database details after creation: %v", err),
)
return
}
// Map response body to schema
err = mapResourceFields(database, &model)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
funcErrorSummary,
fmt.Sprintf("map resource fields: %v", err),
)
return
}
// Set state to fully populated data
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database created")
}
// Read refreshes the Terraform state with the latest data.
func (r *databaseResource) Read(
ctx context.Context,
req resource.ReadRequest,
resp *resource.ReadResponse,
) { // nolint:gocritic // function signature required by Terraform
var model resourceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
region := model.Region.ValueString()
databaseId := model.DatabaseId.ValueInt64()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "database_id", databaseId)
databaseResp, err := postgresflexalphaWait.GetDatabaseByIdWaitHandler(ctx, r.client.DefaultAPI, projectId, instanceId, region, databaseId).
SetTimeout(15 * time.Minute).
SetSleepBeforeWait(15 * time.Second).
WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
fmt.Sprintf("Getting database details after creation: %v", err),
)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapResourceFields(databaseResp, &model)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Save identity into Terraform state
identity := DatabaseResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
DatabaseID: types.Int64Value(int64(databaseResp.GetId())),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *databaseResource) Update(
ctx context.Context,
req resource.UpdateRequest,
resp *resource.UpdateResponse,
) {
var model resourceModel
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
region := model.Region.ValueString()
databaseId := model.DatabaseId.ValueInt64()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "database_id", databaseId)
// Retrieve values from state
var stateModel resourceModel
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
modified := false
var payload v3alpha1api.UpdateDatabasePartiallyRequestPayload
if stateModel.Name != model.Name {
payload.Name = model.Name.ValueStringPointer()
modified = true
}
if stateModel.Owner != model.Owner {
payload.Owner = model.Owner.ValueStringPointer()
modified = true
}
if !modified {
tflog.Info(ctx, "no modification detected")
return
}
if databaseId > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "error updating database", "databaseID out of bounds for int32")
return
}
databaseID32 := int32(databaseId) //nolint:gosec // TODO
// Update existing database
err := r.client.DefaultAPI.UpdateDatabasePartiallyRequest(
ctx,
projectId,
region,
instanceId,
databaseID32,
).UpdateDatabasePartiallyRequestPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "error updating database", err.Error())
return
}
ctx = core.LogResponse(ctx)
databaseResp, err := postgresflexalphaWait.GetDatabaseByIdWaitHandler(ctx, r.client.DefaultAPI, projectId, instanceId, region, databaseId).
SetTimeout(15 * time.Minute).
SetSleepBeforeWait(15 * time.Second).
WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "error updating database", err.Error())
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapResourceFields(databaseResp, &model)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Save identity into Terraform state
identity := DatabaseResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
DatabaseID: types.Int64Value(databaseId),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
// Set state to fully populated data
resp.Diagnostics.Append(resp.State.Set(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *databaseResource) Delete(
ctx context.Context,
req resource.DeleteRequest,
resp *resource.DeleteResponse,
) { // nolint:gocritic // function signature required by Terraform
var model resourceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Read identity data
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId, region, instanceId, databaseId64, errExt := r.extractIdentityData(model, identityData)
if errExt != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
extractErrorSummary,
fmt.Sprintf(extractErrorMessage, errExt),
)
}
if databaseId64 > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error in type conversion", "int value too large (databaseId)")
return
}
databaseId := int32(databaseId64) // nolint:gosec // check is performed above
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "database_id", databaseId)
// Delete existing record set
err := r.client.DefaultAPI.DeleteDatabaseRequest(ctx, projectId, region, instanceId, databaseId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting database", fmt.Sprintf("Calling API: %v", err))
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Postgres Flex database deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected import identifier format is: [project_id],[region],[instance_id],[database_id]
func (r *databaseResource) ImportState(
ctx context.Context,
req resource.ImportStateRequest,
resp *resource.ImportStateResponse,
) {
ctx = core.InitProviderContext(ctx)
if req.ID != "" {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(
ctx, &resp.Diagnostics,
"Error importing database",
fmt.Sprintf(
"Expected import identifier with format [project_id],[region],[instance_id],[database_id], got %q",
req.ID,
),
)
return
}
databaseId, err := strconv.ParseInt(idParts[3], 10, 64)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error importing database",
fmt.Sprintf("Invalid database_id format: %q. It must be a valid integer.", idParts[3]),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[2])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), databaseId)...)
core.LogAndAddWarning(
ctx,
&resp.Diagnostics,
"Postgresflex database imported with empty password",
"The database password is not imported as it is only available upon creation of a new database. The password field will be empty.",
)
tflog.Info(ctx, "Postgres Flex database state imported")
return
}
// If no ID is provided, attempt to read identity attributes from the import configuration
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
projectId := identityData.ProjectID.ValueString()
region := identityData.Region.ValueString()
instanceId := identityData.InstanceID.ValueString()
databaseId := identityData.DatabaseID.ValueInt64()
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), region)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), instanceId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), databaseId)...)
tflog.Info(ctx, "Postgres Flex database state imported")
}
// extractIdentityData extracts essential identifiers from the resource model, falling back to the identity model.
func (r *databaseResource) extractIdentityData(
model resourceModel,
identity DatabaseResourceIdentityModel,
) (projectId, region, instanceId string, databaseId int64, err error) {
if !model.DatabaseId.IsNull() && !model.DatabaseId.IsUnknown() {
databaseId = model.DatabaseId.ValueInt64()
} else {
if identity.DatabaseID.IsNull() || identity.DatabaseID.IsUnknown() {
return "", "", "", 0, fmt.Errorf("database_id not found in config")
}
databaseId = identity.DatabaseID.ValueInt64()
}
if !model.ProjectId.IsNull() && !model.ProjectId.IsUnknown() {
projectId = model.ProjectId.ValueString()
} else {
if identity.ProjectID.IsNull() || identity.ProjectID.IsUnknown() {
return "", "", "", 0, fmt.Errorf("project_id not found in config")
}
projectId = identity.ProjectID.ValueString()
}
if !model.Region.IsNull() && !model.Region.IsUnknown() {
region = r.providerData.GetRegionWithOverride(model.Region)
} else {
if identity.Region.IsNull() || identity.Region.IsUnknown() {
return "", "", "", 0, fmt.Errorf("region not found in config")
}
region = r.providerData.GetRegionWithOverride(identity.Region)
}
if !model.InstanceId.IsNull() && !model.InstanceId.IsUnknown() {
instanceId = model.InstanceId.ValueString()
} else {
if identity.InstanceID.IsNull() || identity.InstanceID.IsUnknown() {
return "", "", "", 0, fmt.Errorf("instance_id not found in config")
}
instanceId = identity.InstanceID.ValueString()
}
return projectId, region, instanceId, databaseId, nil
}

View file

@ -1,447 +0,0 @@
package postgresflexa
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
"github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflexalpha/utils"
"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"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &databaseResource{}
_ resource.ResourceWithConfigure = &databaseResource{}
_ resource.ResourceWithImportState = &databaseResource{}
_ resource.ResourceWithModifyPlan = &databaseResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
DatabaseId types.String `tfsdk:"database_id"`
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
Owner types.String `tfsdk:"owner"`
Region types.String `tfsdk:"region"`
Encryption encryptionModel `tfsdk:"encryption"`
}
type encryptionModel struct {
KeyId types.String `tfsdk:"key_id"`
//keyringid = xxxx
//keyversion = xxxx
//serviceaccount = xxxx
}
// NewDatabaseResource is a helper function to simplify the provider implementation.
func NewDatabaseResource() resource.Resource {
return &databaseResource{}
}
// databaseResource is the resource implementation.
type databaseResource struct {
client *postgresflexalpha.APIClient
providerData core.ProviderData
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *databaseResource) 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.GetRegion(), 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.
func (r *databaseResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflex_database"
}
// Configure adds the provider configured client to the resource.
func (r *databaseResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex database client configured")
}
// Schema defines the schema for the resource.
func (r *databaseResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex database resource schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`,`database_id`\".",
"database_id": "Database ID.",
"instance_id": "ID of the Postgres Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Database name.",
"owner": "Username of the database owner.",
"region": "The resource region. If not defined, the provider region is used.",
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"database_id": schema.StringAttribute{
Description: descriptions["database_id"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"owner": schema.StringAttribute{
Description: descriptions["owner"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"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(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *databaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
instanceId := model.InstanceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new database
databaseResp, err := r.client.CreateDatabaseRequest(ctx, projectId, region, instanceId).CreateDatabaseRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if databaseResp == nil || databaseResp.Id == nil || *databaseResp.Id == "" {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", "API didn't return database Id. A database might have been created")
return
}
databaseId := *databaseResp.Id
ctx = tflog.SetField(ctx, "database_id", databaseId)
database, err := getDatabase(ctx, r.client, projectId, region, instanceId, databaseId)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Getting database details after creation: %v", err))
return
}
// Map response body to schema
err = mapFields(database, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database created")
}
// Read refreshes the Terraform state with the latest data.
func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
databaseId := model.DatabaseId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
databaseResp, err := getDatabase(ctx, r.client, projectId, region, instanceId, databaseId)
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) || errors.Is(err, databaseNotFoundErr) {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(databaseResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *databaseResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update shouldn't be called
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", "Database can't be updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *databaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
databaseId := model.DatabaseId.ValueString()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
// Delete existing record set
err := r.client.DeleteDatabase(ctx, projectId, region, instanceId, databaseId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting database", fmt.Sprintf("Calling API: %v", err))
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Postgres Flex database deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,zone_id,record_set_id
func (r *databaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing database",
fmt.Sprintf("Expected import identifier with format [project_id],[region],[instance_id],[database_id], got %q", req.ID),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[2])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), idParts[3])...)
core.LogAndAddWarning(ctx, &resp.Diagnostics,
"Postgresflex database imported with empty password",
"The database password is not imported as it is only available upon creation of a new database. The password field will be empty.",
)
tflog.Info(ctx, "Postgres Flex database state imported")
}
func mapFields(databaseResp *postgresflex.InstanceDatabase, model *Model, region string) error {
if databaseResp == nil {
return fmt.Errorf("response is nil")
}
if databaseResp.Id == nil || *databaseResp.Id == "" {
return fmt.Errorf("id not present")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var databaseId string
if model.DatabaseId.ValueString() != "" {
databaseId = model.DatabaseId.ValueString()
} else if databaseResp.Id != nil {
databaseId = *databaseResp.Id
} else {
return fmt.Errorf("database id not present")
}
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), databaseId,
)
model.DatabaseId = types.StringValue(databaseId)
model.Name = types.StringPointerValue(databaseResp.Name)
model.Region = types.StringValue(region)
if databaseResp.Options != nil {
owner, ok := (*databaseResp.Options)["owner"]
if ok {
ownerStr, ok := owner.(string)
if !ok {
return fmt.Errorf("owner is not a string")
}
// If the field is returned between with quotes, we trim them to prevent an inconsistent result after apply
ownerStr = strings.TrimPrefix(ownerStr, `"`)
ownerStr = strings.TrimSuffix(ownerStr, `"`)
model.Owner = types.StringValue(ownerStr)
}
}
return nil
}
func toCreatePayload(model *Model) (*postgresflexalpha.CreateDatabaseRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &postgresflexalpha.CreateDatabaseRequestPayload{
Name: model.Name.ValueStringPointer(),
// TODO
//Options: &map[string]string{
// "owner": model.Owner.ValueString(),
//},
}, nil
}
var databaseNotFoundErr = errors.New("database not found")
// The API does not have a GetDatabase endpoint, only ListDatabases
func getDatabase(ctx context.Context, client *postgresflexalpha.APIClient, projectId, region, instanceId, databaseId string) (*postgresflex.InstanceDatabase, error) {
resp, err := client.ListDatabasesRequest(ctx, projectId, region, instanceId).Execute()
if err != nil {
return nil, err
}
if resp == nil || resp.Databases == nil {
return nil, fmt.Errorf("response is nil")
}
for _, database := range *resp.Databases {
if database.Id != nil && *database.Id == databaseId {
return &database, nil
}
}
return nil, databaseNotFoundErr
}

View file

@ -1,192 +0,0 @@
// Copyright (c) STACKIT
package postgresflexa
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.InstanceDatabase
region string
expected Model
isValid bool
}{
{
"default_values",
&postgresflex.InstanceDatabase{
Id: utils.Ptr("uid"),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
DatabaseId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringNull(),
Owner: types.StringNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
&postgresflex.InstanceDatabase{
Id: utils.Ptr("uid"),
Name: utils.Ptr("dbname"),
Options: &map[string]interface{}{
"owner": "username",
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
DatabaseId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("dbname"),
Owner: types.StringValue("username"),
Region: types.StringValue(testRegion),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.InstanceDatabase{
Id: utils.Ptr("uid"),
Name: utils.Ptr(""),
Options: &map[string]interface{}{
"owner": "",
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
DatabaseId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Owner: types.StringValue(""),
Region: types.StringValue(testRegion),
},
true,
},
{
"nil_response",
nil,
testRegion,
Model{},
false,
},
{
"empty_response",
&postgresflex.InstanceDatabase{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
&postgresflex.InstanceDatabase{
Id: utils.Ptr(""),
Name: utils.Ptr("dbname"),
Options: &map[string]interface{}{
"owner": "username",
},
},
testRegion,
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
state := &Model{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
}
err := mapFields(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(state, &tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *postgresflex.CreateDatabasePayload
isValid bool
}{
{
"default_values",
&Model{
Name: types.StringValue("dbname"),
Owner: types.StringValue("username"),
},
&postgresflex.CreateDatabasePayload{
Name: utils.Ptr("dbname"),
Options: &map[string]string{
"owner": "username",
},
},
true,
},
{
"null_fields",
&Model{
Name: types.StringNull(),
Owner: types.StringNull(),
},
&postgresflex.CreateDatabasePayload{
Name: nil,
Options: &map[string]string{
"owner": "",
},
},
true,
},
{
"nil_model",
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,74 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package postgresflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
)
func DatabaseResourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"database_id": schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "The ID of the database.",
MarkdownDescription: "The ID of the database.",
},
"id": schema.Int64Attribute{
Computed: true,
Description: "The id of the database.",
MarkdownDescription: "The id of the database.",
},
"instance_id": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The ID of the instance.",
MarkdownDescription: "The ID of the instance.",
},
"name": schema.StringAttribute{
Required: true,
Description: "The name of the database.",
MarkdownDescription: "The name of the database.",
},
"owner": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The owner of the database.",
MarkdownDescription: "The owner of the database.",
},
"project_id": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
},
}
}
type DatabaseModel struct {
DatabaseId types.Int64 `tfsdk:"database_id"`
Id types.Int64 `tfsdk:"id"`
InstanceId types.String `tfsdk:"instance_id"`
Name types.String `tfsdk:"name"`
Owner types.String `tfsdk:"owner"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
}

View file

@ -0,0 +1,253 @@
package postgresflexalphaflavor
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
postgresflexalphaGen "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/flavors/datasources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-log/tflog"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &flavorDataSource{}
)
type FlavorModel struct {
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
StorageClass types.String `tfsdk:"storage_class"`
Cpu types.Int32 `tfsdk:"cpu"`
Description types.String `tfsdk:"description"`
Id types.String `tfsdk:"id"`
FlavorId types.String `tfsdk:"flavor_id"`
MaxGb types.Int32 `tfsdk:"max_gb"`
Memory types.Int32 `tfsdk:"ram"`
MinGb types.Int32 `tfsdk:"min_gb"`
NodeType types.String `tfsdk:"node_type"`
StorageClasses types.List `tfsdk:"storage_classes"`
}
// NewFlavorDataSource is a helper function to simplify the provider implementation.
func NewFlavorDataSource() datasource.DataSource {
return &flavorDataSource{}
}
// flavorDataSource is the data source implementation.
type flavorDataSource struct {
client *v3alpha1api.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (r *flavorDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_flavor"
}
// Configure adds the provider configured client to the data source.
func (r *flavorDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex instance client configured")
}
func (r *flavorDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"project_id": schema.StringAttribute{
Required: true,
Description: "The cpu count of the instance.",
MarkdownDescription: "The cpu count of the instance.",
},
"region": schema.StringAttribute{
Required: true,
Description: "The flavor description.",
MarkdownDescription: "The flavor description.",
},
"cpu": schema.Int32Attribute{
Required: true,
Description: "The cpu count of the instance.",
MarkdownDescription: "The cpu count of the instance.",
},
"ram": schema.Int32Attribute{
Required: true,
Description: "The memory of the instance in Gibibyte.",
MarkdownDescription: "The memory of the instance in Gibibyte.",
},
"storage_class": schema.StringAttribute{
Required: true,
Description: "The memory of the instance in Gibibyte.",
MarkdownDescription: "The memory of the instance in Gibibyte.",
},
"description": schema.StringAttribute{
Computed: true,
Description: "The flavor description.",
MarkdownDescription: "The flavor description.",
},
"id": schema.StringAttribute{
Computed: true,
Description: "The terraform id of the instance flavor.",
MarkdownDescription: "The terraform id of the instance flavor.",
},
"flavor_id": schema.StringAttribute{
Computed: true,
Description: "The flavor id of the instance flavor.",
MarkdownDescription: "The flavor id of the instance flavor.",
},
"max_gb": schema.Int32Attribute{
Computed: true,
Description: "maximum storage which can be ordered for the flavor in Gigabyte.",
MarkdownDescription: "maximum storage which can be ordered for the flavor in Gigabyte.",
},
"min_gb": schema.Int32Attribute{
Computed: true,
Description: "minimum storage which is required to order in Gigabyte.",
MarkdownDescription: "minimum storage which is required to order in Gigabyte.",
},
"node_type": schema.StringAttribute{
Required: true,
Description: "defines the nodeType it can be either single or replica",
MarkdownDescription: "defines the nodeType it can be either single or replica",
},
"storage_classes": schema.ListNestedAttribute{
Computed: true,
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"class": schema.StringAttribute{
Computed: true,
},
"max_io_per_sec": schema.Int32Attribute{
Computed: true,
},
"max_through_in_mb": schema.Int32Attribute{
Computed: true,
},
},
CustomType: postgresflexalphaGen.StorageClassesType{
ObjectType: types.ObjectType{
AttrTypes: postgresflexalphaGen.StorageClassesValue{}.AttributeTypes(ctx),
},
},
},
},
},
}
}
func (r *flavorDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var model FlavorModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
flavors, err := getAllFlavors(ctx, r.client.DefaultAPI, projectId, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading flavors", fmt.Sprintf("getAllFlavors: %v", err))
return
}
var foundFlavors []v3alpha1api.ListFlavors
for _, flavor := range flavors {
if model.Cpu.ValueInt32() != flavor.Cpu {
continue
}
if model.Memory.ValueInt32() != flavor.Memory {
continue
}
if model.NodeType.ValueString() != flavor.NodeType {
continue
}
for _, sc := range flavor.StorageClasses {
if model.StorageClass.ValueString() != sc.Class {
continue
}
foundFlavors = append(foundFlavors, flavor)
}
}
if len(foundFlavors) == 0 {
resp.Diagnostics.AddError("get flavor", "could not find requested flavor")
return
}
if len(foundFlavors) > 1 {
resp.Diagnostics.AddError("get flavor", "found too many matching flavors")
return
}
f := foundFlavors[0]
model.Description = types.StringValue(f.Description)
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, f.Id)
model.FlavorId = types.StringValue(f.Id)
model.MaxGb = types.Int32Value(f.MaxGB)
model.MinGb = types.Int32Value(f.MinGB)
if f.StorageClasses == nil {
model.StorageClasses = types.ListNull(postgresflexalphaGen.StorageClassesType{
ObjectType: basetypes.ObjectType{
AttrTypes: postgresflexalphaGen.StorageClassesValue{}.AttributeTypes(ctx),
},
})
} else {
var scList []attr.Value
for _, sc := range f.StorageClasses {
scList = append(
scList,
postgresflexalphaGen.NewStorageClassesValueMust(
postgresflexalphaGen.StorageClassesValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"class": types.StringValue(sc.Class),
"max_io_per_sec": types.Int32Value(sc.MaxIoPerSec),
"max_through_in_mb": types.Int32Value(sc.MaxThroughInMb),
},
),
)
}
storageClassesList := types.ListValueMust(
postgresflexalphaGen.StorageClassesType{
ObjectType: basetypes.ObjectType{
AttrTypes: postgresflexalphaGen.StorageClassesValue{}.AttributeTypes(ctx),
},
},
scList,
)
model.StorageClasses = storageClassesList
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex flavors read")
}

View file

@ -0,0 +1,65 @@
package postgresflexalphaflavor
import (
"context"
"fmt"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
)
type flavorsClientReader interface {
GetFlavorsRequest(
ctx context.Context,
projectId, region string,
) v3alpha1api.ApiGetFlavorsRequestRequest
}
func getAllFlavors(ctx context.Context, client flavorsClientReader, projectId, region string) (
[]v3alpha1api.ListFlavors,
error,
) {
getAllFilter := func(_ v3alpha1api.ListFlavors) bool { return true }
flavorList, err := getFlavorsByFilter(ctx, client, projectId, region, getAllFilter)
if err != nil {
return nil, err
}
return flavorList, nil
}
// getFlavorsByFilter is a helper function to retrieve flavors using a filtern function.
// Hint: The API does not have a GetFlavors endpoint, only ListFlavors
func getFlavorsByFilter(
ctx context.Context,
client flavorsClientReader,
projectId, region string,
filter func(db v3alpha1api.ListFlavors) bool,
) ([]v3alpha1api.ListFlavors, error) {
if projectId == "" || region == "" {
return nil, fmt.Errorf("listing postgresflex flavors: projectId and region are required")
}
const pageSize = 25
var result = make([]v3alpha1api.ListFlavors, 0)
for page := int32(1); ; page++ {
res, err := client.GetFlavorsRequest(ctx, projectId, region).
Page(page).Size(pageSize).Sort(v3alpha1api.FLAVORSORT_ID_ASC).Execute()
if err != nil {
return nil, fmt.Errorf("requesting flavors list (page %d): %w", page, err)
}
// If the API returns no flavors, we have reached the end of the list.
if len(res.Flavors) == 0 {
break
}
for _, flavor := range res.Flavors {
if filter(flavor) {
result = append(result, flavor)
}
}
}
return result, nil
}

View file

@ -0,0 +1,135 @@
package postgresflexalphaflavor
/*
import (
"context"
"testing"
postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
)
type mockRequest struct {
executeFunc func() (*postgresflex.GetFlavorsResponse, error)
}
func (m *mockRequest) Page(_ int32) postgresflex.ApiGetFlavorsRequestRequest { return m }
func (m *mockRequest) Size(_ int32) postgresflex.ApiGetFlavorsRequestRequest { return m }
func (m *mockRequest) Sort(_ postgresflex.FlavorSort) postgresflex.ApiGetFlavorsRequestRequest {
return m
}
func (m *mockRequest) Execute() (*postgresflex.GetFlavorsResponse, error) {
return m.executeFunc()
}
type mockFlavorsClient struct {
executeRequest func() postgresflex.ApiGetFlavorsRequestRequest
}
func (m *mockFlavorsClient) GetFlavorsRequest(_ context.Context, _, _ string) postgresflex.ApiGetFlavorsRequestRequest {
return m.executeRequest()
}
var mockResp = func(page int32) (*postgresflex.GetFlavorsResponse, error) {
if page == 1 {
return &postgresflex.GetFlavorsResponse{
Flavors: []postgresflex.ListFlavors{
{Id: "flavor-1", Description: "first"},
{Id: "flavor-2", Description: "second"},
},
}, nil
}
if page == 2 {
return &postgresflex.GetFlavorsResponse{
Flavors: []postgresflex.ListFlavors{
{Id: "flavor-3", Description: "three"},
},
}, nil
}
return &postgresflex.GetFlavorsResponse{
Flavors: []postgresflex.ListFlavors{},
}, nil
}
func TestGetFlavorsByFilter(t *testing.T) {
tests := []struct {
description string
projectId string
region string
mockErr error
filter func(postgresflex.ListFlavors) bool
wantCount int
wantErr bool
}{
{
description: "Success - Get all flavors (2 pages)",
projectId: "pid", region: "reg",
filter: func(_ postgresflex.ListFlavors) bool { return true },
wantCount: 3,
wantErr: false,
},
{
description: "Success - Filter flavors by description",
projectId: "pid", region: "reg",
filter: func(f postgresflex.ListFlavors) bool { return f.Description == "first" },
wantCount: 1,
wantErr: false,
},
{
description: "Error - Missing parameters",
projectId: "", region: "reg",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
var currentPage int32
client := &mockFlavorsClient{
executeRequest: func() postgresflex.ApiGetFlavorsRequestRequest {
return mockRequest{
executeFunc: func() (*postgresflex.GetFlavorsResponse, error) {
currentPage++
return mockResp(currentPage)
},
}
},
}
actual, err := getFlavorsByFilter(context.Background(), client, tt.projectId, tt.region, tt.filter)
if (err != nil) != tt.wantErr {
t.Errorf("getFlavorsByFilter() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(actual) != tt.wantCount {
t.Errorf("getFlavorsByFilter() got %d flavors, want %d", len(actual), tt.wantCount)
}
},
)
}
}
func TestGetAllFlavors(t *testing.T) {
var currentPage int32
client := &mockFlavorsClient{
executeRequest: func() postgresflex.ApiGetFlavorsRequestRequest {
return mockRequest{
executeFunc: func() (*postgresflex.GetFlavorsResponse, error) {
currentPage++
return mockResp(currentPage)
},
}
},
}
res, err := getAllFlavors(context.Background(), client, "pid", "reg")
if err != nil {
t.Errorf("getAllFlavors() unexpected error: %v", err)
}
if len(res) != 3 {
t.Errorf("getAllFlavors() expected 3 flavor, got %d", len(res))
}
}
*/

View file

@ -0,0 +1,82 @@
package postgresflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
postgresflexalphaGen "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/flavors/datasources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
)
var (
_ datasource.DataSource = &flavorsDataSource{}
_ datasource.DataSourceWithConfigure = &flavorsDataSource{}
)
func NewFlavorsDataSource() datasource.DataSource {
return &flavorsDataSource{}
}
// dataSourceModel maps the data source schema data.
type dataSourceModel = postgresflexalphaGen.FlavorsModel
type flavorsDataSource struct {
client *v3alpha1api.APIClient
providerData core.ProviderData
}
func (d *flavorsDataSource) Metadata(
_ context.Context,
req datasource.MetadataRequest,
resp *datasource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_flavors"
}
func (d *flavorsDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = postgresflexalphaGen.FlavorsDataSourceSchema(ctx)
}
// Configure adds the provider configured client to the data source.
func (d *flavorsDataSource) Configure(
ctx context.Context,
req datasource.ConfigureRequest,
resp *datasource.ConfigureResponse,
) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "Postgres Flex version client configured")
}
func (d *flavorsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var data dataSourceModel
// Read Terraform configuration data into the model
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Todo: Read API call logic
// Example data value setting
// data.Id = types.StringValue("example-id")
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

View file

@ -0,0 +1,130 @@
package postgresflexalpha
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
postgresflexalpha2 "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/instance/datasources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-log/tflog"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &instanceDataSource{}
)
// NewInstanceDataSource is a helper function to simplify the provider implementation.
func NewInstanceDataSource() datasource.DataSource {
return &instanceDataSource{}
}
// dataSourceModel maps the data source schema data.
type dataSourceModel struct {
postgresflexalpha2.InstanceModel
TerraformID types.String `tfsdk:"id"`
}
// instanceDataSource is the data source implementation.
type instanceDataSource struct {
client *v3alpha1api.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (r *instanceDataSource) Metadata(
_ context.Context,
req datasource.MetadataRequest,
resp *datasource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_instance"
}
// Configure adds the provider configured client to the data source.
func (r *instanceDataSource) Configure(
ctx context.Context,
req datasource.ConfigureRequest,
resp *datasource.ConfigureResponse,
) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex instance client configured")
}
// Schema defines the schema for the data source.
func (r *instanceDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = postgresflexalpha2.InstanceDataSourceSchema(ctx)
}
// Read refreshes the Terraform state with the latest data.
func (r *instanceDataSource) Read(
ctx context.Context,
req datasource.ReadRequest,
resp *datasource.ReadResponse,
) { // nolint:gocritic // function signature required by Terraform
var model dataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
instanceResp, err := r.client.DefaultAPI.GetInstanceRequest(ctx, projectId, region, instanceId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading instance",
fmt.Sprintf("Instance with ID %q does not exist in project %q.", instanceId, projectId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
err = mapGetDataInstanceResponseToModel(ctx, &model, instanceResp)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex instance read")
}

View file

@ -1,222 +0,0 @@
package postgresflexa
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflexalpha/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"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"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha/wait"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &instanceDataSource{}
)
// NewInstanceDataSource is a helper function to simplify the provider implementation.
func NewInstanceDataSource() datasource.DataSource {
return &instanceDataSource{}
}
// instanceDataSource is the data source implementation.
type instanceDataSource struct {
client *postgresflexalpha.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (r *instanceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_instance"
}
// Configure adds the provider configured client to the data source.
func (r *instanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex instance client configured")
}
// Schema defines the schema for the data source.
func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex instance data source schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`instance_id`\".",
"instance_id": "ID of the PostgresFlex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Instance name.",
"acl": "The Access Control List (ACL) for the PostgresFlex instance.",
"region": "The resource region. If not defined, the provider region is used.",
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Computed: true,
},
"acl": schema.ListAttribute{
Description: descriptions["acl"],
ElementType: types.StringType,
Computed: true,
},
"backup_schedule": schema.StringAttribute{
Computed: true,
},
"flavor": schema.SingleNestedAttribute{
Computed: true,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
},
"description": schema.StringAttribute{
Computed: true,
},
"cpu": schema.Int64Attribute{
Computed: true,
},
"ram": schema.Int64Attribute{
Computed: true,
},
},
},
"replicas": schema.Int64Attribute{
Computed: true,
},
"storage": schema.SingleNestedAttribute{
Computed: true,
Attributes: map[string]schema.Attribute{
"class": schema.StringAttribute{
Computed: true,
},
"size": schema.Int64Attribute{
Computed: true,
},
},
},
"version": schema.StringAttribute{
Computed: true,
},
"region": schema.StringAttribute{
// the region cannot be found, so it has to be passed
Optional: true,
Description: descriptions["region"],
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (r *instanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
instanceResp, err := r.client.GetInstanceRequest(ctx, projectId, region, instanceId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading instance",
fmt.Sprintf("Instance with ID %q does not exist in project %q.", instanceId, projectId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
if instanceResp != nil && instanceResp.Status != nil && *instanceResp.Status == wait.InstanceStateDeleted {
resp.State.RemoveResource(ctx)
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", "Instance was deleted successfully")
return
}
var flavor = &flavorModel{}
if !(model.Flavor.IsNull() || model.Flavor.IsUnknown()) {
diags = model.Flavor.As(ctx, flavor, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var storage = &storageModel{}
if !(model.Storage.IsNull() || model.Storage.IsUnknown()) {
diags = model.Storage.As(ctx, storage, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
err = mapFields(ctx, instanceResp, &model, flavor, storage, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex instance read")
}

View file

@ -0,0 +1,241 @@
package postgresflexalpha
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
postgresflexalphadatasource "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/instance/datasources_gen"
postgresflexalpharesource "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/instance/resources_gen"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
func mapGetInstanceResponseToModel(
ctx context.Context,
m *postgresflexalpharesource.InstanceModel,
resp *postgresflex.GetInstanceResponse,
) error {
m.BackupSchedule = types.StringValue(resp.GetBackupSchedule())
m.Encryption = postgresflexalpharesource.NewEncryptionValueNull()
if resp.HasEncryption() {
m.Encryption = postgresflexalpharesource.NewEncryptionValueMust(
m.Encryption.AttributeTypes(ctx),
map[string]attr.Value{
"kek_key_id": types.StringValue(resp.Encryption.GetKekKeyId()),
"kek_key_ring_id": types.StringValue(resp.Encryption.GetKekKeyRingId()),
"kek_key_version": types.StringValue(resp.Encryption.GetKekKeyVersion()),
"service_account": types.StringValue(resp.Encryption.GetServiceAccount()),
},
)
}
isConnectionInfoIncomplete := resp.ConnectionInfo.Write.Host == "" || resp.ConnectionInfo.Write.Port == 0
if isConnectionInfoIncomplete {
m.ConnectionInfo = postgresflexalpharesource.NewConnectionInfoValueNull()
} else {
m.ConnectionInfo = postgresflexalpharesource.NewConnectionInfoValueMust(
postgresflexalpharesource.ConnectionInfoValue{}.AttributeTypes(ctx),
map[string]attr.Value{
// careful - we can not use NewWriteValueMust here
"write": basetypes.NewObjectValueMust(
postgresflexalpharesource.WriteValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"host": types.StringValue(resp.ConnectionInfo.Write.Host),
// note: IDE does not show that port is actually an int64 in the Schema
"port": types.Int64Value(int64(resp.ConnectionInfo.Write.Port)),
},
),
},
)
}
m.FlavorId = types.StringValue(resp.GetFlavorId())
if m.Id.IsNull() || m.Id.IsUnknown() {
m.Id = utils.BuildInternalTerraformId(
m.ProjectId.ValueString(),
m.Region.ValueString(),
m.InstanceId.ValueString(),
)
}
m.InstanceId = types.StringValue(resp.Id)
m.IsDeletable = types.BoolValue(resp.GetIsDeletable())
netAcl, diags := types.ListValueFrom(ctx, types.StringType, resp.Network.GetAcl())
if diags.HasError() {
return fmt.Errorf("failed converting network acl from response")
}
m.Acl = netAcl
netInstAdd := types.StringValue("")
if instAdd, ok := resp.Network.GetInstanceAddressOk(); ok {
netInstAdd = types.StringValue(*instAdd)
}
netRtrAdd := types.StringValue("")
if rtrAdd, ok := resp.Network.GetRouterAddressOk(); ok {
netRtrAdd = types.StringValue(*rtrAdd)
}
net, diags := postgresflexalpharesource.NewNetworkValue(
postgresflexalpharesource.NetworkValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"access_scope": basetypes.NewStringValue(string(resp.Network.GetAccessScope())),
"acl": netAcl,
"instance_address": netInstAdd,
"router_address": netRtrAdd,
},
)
if diags.HasError() {
return fmt.Errorf("failed converting network from response")
}
m.Network = net
m.Replicas = types.Int64Value(int64(resp.GetReplicas()))
m.RetentionDays = types.Int64Value(int64(resp.GetRetentionDays()))
m.Name = types.StringValue(resp.GetName())
m.Status = types.StringValue(string(resp.GetStatus()))
storage, diags := postgresflexalpharesource.NewStorageValue(
postgresflexalpharesource.StorageValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"performance_class": types.StringValue(resp.Storage.GetPerformanceClass()),
"size": types.Int64Value(int64(resp.Storage.GetSize())),
},
)
if diags.HasError() {
return fmt.Errorf("failed converting storage from response")
}
m.Storage = storage
m.Version = types.StringValue(resp.GetVersion())
return nil
}
func mapGetDataInstanceResponseToModel(
ctx context.Context,
m *dataSourceModel,
resp *postgresflex.GetInstanceResponse,
) error {
m.BackupSchedule = types.StringValue(resp.GetBackupSchedule())
handleEncryption(m, resp)
handleConnectionInfo(ctx, m, resp)
m.FlavorId = types.StringValue(resp.GetFlavorId())
m.Id = utils.BuildInternalTerraformId(m.ProjectId.ValueString(), m.Region.ValueString(), m.InstanceId.ValueString())
m.InstanceId = types.StringValue(resp.Id)
m.IsDeletable = types.BoolValue(resp.GetIsDeletable())
m.Name = types.StringValue(resp.GetName())
err := handleNetwork(ctx, m, resp)
if err != nil {
return err
}
m.Replicas = types.Int64Value(int64(resp.GetReplicas()))
m.RetentionDays = types.Int64Value(int64(resp.GetRetentionDays()))
m.Status = types.StringValue(string(resp.GetStatus()))
storage, diags := postgresflexalphadatasource.NewStorageValue(
postgresflexalphadatasource.StorageValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"performance_class": types.StringValue(resp.Storage.GetPerformanceClass()),
"size": types.Int64Value(int64(resp.Storage.GetSize())),
},
)
if diags.HasError() {
return fmt.Errorf("failed converting storage from response")
}
m.Storage = storage
m.Version = types.StringValue(resp.GetVersion())
return nil
}
func handleConnectionInfo(ctx context.Context, m *dataSourceModel, resp *postgresflex.GetInstanceResponse) {
isConnectionInfoIncomplete := resp.ConnectionInfo.Write.Host == "" || resp.ConnectionInfo.Write.Port == 0
if isConnectionInfoIncomplete {
m.ConnectionInfo = postgresflexalphadatasource.NewConnectionInfoValueNull()
} else {
m.ConnectionInfo = postgresflexalphadatasource.NewConnectionInfoValueMust(
postgresflexalphadatasource.ConnectionInfoValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"write": types.ObjectValueMust(
postgresflexalphadatasource.WriteValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"host": types.StringValue(resp.ConnectionInfo.Write.Host),
"port": types.Int64Value(int64(resp.ConnectionInfo.Write.Port)),
},
),
},
)
}
}
func handleNetwork(ctx context.Context, m *dataSourceModel, resp *postgresflex.GetInstanceResponse) error {
netACL, diags := types.ListValueFrom(ctx, types.StringType, resp.Network.GetAcl())
if diags.HasError() {
return fmt.Errorf("failed converting network acl from response")
}
instAddr := ""
if iA, ok := resp.Network.GetInstanceAddressOk(); ok {
instAddr = *iA
}
rtrAddr := ""
if rA, ok := resp.Network.GetRouterAddressOk(); ok {
rtrAddr = *rA
}
net, diags := postgresflexalphadatasource.NewNetworkValue(
postgresflexalphadatasource.NetworkValue{}.AttributeTypes(ctx),
map[string]attr.Value{
"access_scope": types.StringValue(string(resp.Network.GetAccessScope())),
"acl": netACL,
"instance_address": types.StringValue(instAddr),
"router_address": types.StringValue(rtrAddr),
},
)
if diags.HasError() {
return fmt.Errorf("failed converting network from response")
}
m.Network = net
return nil
}
func handleEncryption(m *dataSourceModel, resp *postgresflex.GetInstanceResponse) {
keyId := ""
if keyIdVal, ok := resp.Encryption.GetKekKeyIdOk(); ok {
keyId = *keyIdVal
}
keyRingId := ""
if keyRingIdVal, ok := resp.Encryption.GetKekKeyRingIdOk(); ok {
keyRingId = *keyRingIdVal
}
keyVersion := ""
if keyVersionVal, ok := resp.Encryption.GetKekKeyVersionOk(); ok {
keyVersion = *keyVersionVal
}
svcAcc := ""
if svcAccVal, ok := resp.Encryption.GetServiceAccountOk(); ok {
svcAcc = *svcAccVal
}
m.Encryption = postgresflexalphadatasource.EncryptionValue{
KekKeyId: types.StringValue(keyId),
KekKeyRingId: types.StringValue(keyRingId),
KekKeyVersion: types.StringValue(keyVersion),
ServiceAccount: types.StringValue(svcAcc),
}
}

View file

@ -0,0 +1,191 @@
package postgresflexalpha
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/types"
postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
postgresflexalpharesource "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/instance/resources_gen"
utils2 "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
func Test_handleConnectionInfo(t *testing.T) {
type args struct {
ctx context.Context
m *dataSourceModel
hostName string
port int32
}
tests := []struct {
name string
args args
}{
{
name: "empty connection info",
args: args{
ctx: context.TODO(),
m: &dataSourceModel{},
hostName: "",
port: 0,
},
},
{
name: "empty connection info host",
args: args{
ctx: context.TODO(),
m: &dataSourceModel{},
hostName: "",
port: 1234,
},
},
{
name: "empty connection info port",
args: args{
ctx: context.TODO(),
m: &dataSourceModel{},
hostName: "hostname",
port: 0,
},
},
{
name: "valid connection info",
args: args{
ctx: context.TODO(),
m: &dataSourceModel{},
hostName: "host",
port: 1000,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := &postgresflex.GetInstanceResponse{
ConnectionInfo: postgresflex.InstanceConnectionInfo{
Write: postgresflex.InstanceConnectionInfoWrite{
Host: tt.args.hostName,
Port: int32(tt.args.port),
},
},
}
handleConnectionInfo(tt.args.ctx, tt.args.m, resp)
if tt.args.hostName == "" || tt.args.port == 0 {
if !tt.args.m.ConnectionInfo.IsNull() {
t.Errorf("expected connection info to be null")
}
}
if tt.args.hostName != "" && tt.args.port != 0 {
res := tt.args.m.ConnectionInfo.Write.Attributes()
gotHost := ""
if r, ok := res["host"]; ok {
gotHost = utils2.RemoveQuotes(r.String())
}
if gotHost != tt.args.hostName {
t.Errorf("host value incorrect: want: %s - got: %s", tt.args.hostName, gotHost)
}
gotPort, ok := res["port"]
if !ok {
t.Errorf("could not find a value for port in connection_info.write")
}
if !gotPort.Equal(types.Int64Value(int64(tt.args.port))) {
t.Errorf("port value incorrect: want: %d - got: %s", tt.args.port, gotPort.String())
}
}
})
}
}
func Test_handleEncryption(t *testing.T) {
t.Skipf("please implement")
type args struct {
m *dataSourceModel
resp *postgresflex.GetInstanceResponse
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handleEncryption(tt.args.m, tt.args.resp)
t.Logf("need to implement more")
})
}
}
func Test_handleNetwork(t *testing.T) {
t.Skipf("please implement")
type args struct {
ctx context.Context
m *dataSourceModel
resp *postgresflex.GetInstanceResponse
}
tests := []struct {
name string
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := handleNetwork(tt.args.ctx, tt.args.m, tt.args.resp); (err != nil) != tt.wantErr {
t.Errorf("handleNetwork() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func Test_mapGetDataInstanceResponseToModel(t *testing.T) {
t.Skipf("please implement")
type args struct {
ctx context.Context
m *dataSourceModel
resp *postgresflex.GetInstanceResponse
}
tests := []struct {
name string
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := mapGetDataInstanceResponseToModel(tt.args.ctx, tt.args.m, tt.args.resp); (err != nil) != tt.wantErr {
t.Errorf("mapGetDataInstanceResponseToModel() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func Test_mapGetInstanceResponseToModel(t *testing.T) {
t.Skipf("please implement")
type args struct {
ctx context.Context
m *postgresflexalpharesource.InstanceModel
resp *postgresflex.GetInstanceResponse
}
tests := []struct {
name string
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := mapGetInstanceResponseToModel(tt.args.ctx, tt.args.m, tt.args.resp); (err != nil) != tt.wantErr {
t.Errorf("mapGetInstanceResponseToModel() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View file

@ -0,0 +1,120 @@
fields:
- name: 'backup_schedule'
modifiers:
- 'UseStateForUnknown'
- name: 'connection_info.host'
modifiers:
- 'UseStateForUnknown'
- name: 'connection_info.port'
modifiers:
- 'UseStateForUnknown'
- name: 'encryption.kek_key_id'
validators:
- validate.NoSeparator
modifiers:
- 'RequiresReplace'
- name: 'encryption.kek_key_ring_id'
validators:
- validate.NoSeparator
modifiers:
- 'RequiresReplace'
- name: 'encryption.kek_key_version'
validators:
- validate.NoSeparator
modifiers:
- 'RequiresReplace'
- name: 'encryption.service_account'
validators:
- validate.NoSeparator
modifiers:
- 'RequiresReplace'
- name: 'flavor_id'
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'id'
modifiers:
- 'UseStateForUnknown'
- name: 'instance_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'UseStateForUnknown'
- name: 'is_deletable'
modifiers:
- 'UseStateForUnknown'
- name: 'name'
modifiers:
- 'UseStateForUnknown'
- name: 'network.access_scope'
validators:
- validate.NoSeparator
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'network.acl'
modifiers:
- 'UseStateForUnknown'
- name: 'network.instance_address'
modifiers:
- 'UseStateForUnknown'
- name: 'network.router_address'
modifiers:
- 'UseStateForUnknown'
- name: 'project_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'region'
modifiers:
- 'RequiresReplace'
- name: 'replicas'
modifiers:
- 'UseStateForUnknown'
- name: 'retention_days'
modifiers:
- 'UseStateForUnknown'
- name: 'status'
modifiers:
- 'UseStateForUnknown'
- name: 'storage'
modifiers:
- 'UseStateForUnknown'
- name: 'storage.performance_class'
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'storage.size'
modifiers:
- 'UseStateForUnknown'
- name: 'version'
modifiers:
- 'UseStateForUnknown'

View file

@ -1,768 +0,0 @@
// Copyright (c) STACKIT
package postgresflex
import (
"context"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
postgresflex "github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha"
)
type postgresFlexClientMocked struct {
returnError bool
getFlavorsResp *postgresflex.GetFlavorsResponse
}
func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*postgresflex.GetFlavorsResponse, error) {
if c.returnError {
return nil, fmt.Errorf("get flavors failed")
}
return c.getFlavorsResp, nil
}
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
state Model
input *postgresflex.GetInstanceResponse
flavor *flavorModel
storage *storageModel
region string
expected Model
isValid bool
}{
{
"default_values",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
},
&postgresflex.GetInstanceResponse{},
&flavorModel{},
&storageModel{},
testRegion,
Model{
Id: types.StringValue("pid,region,iid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringNull(),
ACL: types.ListNull(types.StringType),
BackupSchedule: types.StringNull(),
Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{
"id": types.StringNull(),
"description": types.StringNull(),
"cpu": types.Int64Null(),
"ram": types.Int64Null(),
}),
Replicas: types.Int64Null(),
Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{
"class": types.StringNull(),
"size": types.Int64Null(),
}),
Version: types.StringNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
},
&postgresflex.GetInstanceResponse{
Acl: &[]string{
"ip1",
"ip2",
"",
},
BackupSchedule: utils.Ptr("schedule"),
//Flavor: &postgresflex.Flavor{
// Cpu: utils.Ptr(int64(12)),
// Description: utils.Ptr("description"),
// Id: utils.Ptr("flavor_id"),
// Memory: utils.Ptr(int64(34)),
//},
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
Replicas: postgresflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(56))),
Status: postgresflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
Storage: &postgresflex.Storage{
PerformanceClass: utils.Ptr("class"),
Size: utils.Ptr(int64(78)),
},
Version: utils.Ptr("version"),
},
&flavorModel{},
&storageModel{},
testRegion,
Model{
Id: types.StringValue("pid,region,iid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("name"),
ACL: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ip1"),
types.StringValue("ip2"),
types.StringValue(""),
}),
BackupSchedule: types.StringValue("schedule"),
Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{
"id": types.StringValue("flavor_id"),
"description": types.StringValue("description"),
"cpu": types.Int64Value(12),
"ram": types.Int64Value(34),
}),
Replicas: types.Int64Value(56),
Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{
"class": types.StringValue("class"),
"size": types.Int64Value(78),
}),
Version: types.StringValue("version"),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values_no_flavor_and_storage",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
},
&postgresflex.GetInstanceResponse{
Acl: &[]string{
"ip1",
"ip2",
"",
},
BackupSchedule: utils.Ptr("schedule"),
FlavorId: nil,
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
Replicas: postgresflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(56))),
Status: postgresflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
Storage: nil,
Version: utils.Ptr("version"),
},
&flavorModel{
CPU: types.Int64Value(12),
RAM: types.Int64Value(34),
},
&storageModel{
Class: types.StringValue("class"),
Size: types.Int64Value(78),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("name"),
ACL: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ip1"),
types.StringValue("ip2"),
types.StringValue(""),
}),
BackupSchedule: types.StringValue("schedule"),
Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{
"id": types.StringNull(),
"description": types.StringNull(),
"cpu": types.Int64Value(12),
"ram": types.Int64Value(34),
}),
Replicas: types.Int64Value(56),
Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{
"class": types.StringValue("class"),
"size": types.Int64Value(78),
}),
Version: types.StringValue("version"),
Region: types.StringValue(testRegion),
},
true,
},
{
"acl_unordered",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
ACL: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ip2"),
types.StringValue(""),
types.StringValue("ip1"),
}),
},
&postgresflex.GetInstanceResponse{
Acl: &[]string{
"",
"ip1",
"ip2",
},
BackupSchedule: utils.Ptr("schedule"),
FlavorId: nil,
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
Replicas: postgresflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(56))),
Status: postgresflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
Storage: nil,
Version: utils.Ptr("version"),
},
&flavorModel{
CPU: types.Int64Value(12),
RAM: types.Int64Value(34),
},
&storageModel{
Class: types.StringValue("class"),
Size: types.Int64Value(78),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("name"),
ACL: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ip2"),
types.StringValue(""),
types.StringValue("ip1"),
}),
BackupSchedule: types.StringValue("schedule"),
Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{
"id": types.StringNull(),
"description": types.StringNull(),
"cpu": types.Int64Value(12),
"ram": types.Int64Value(34),
}),
Replicas: types.Int64Value(56),
Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{
"class": types.StringValue("class"),
"size": types.Int64Value(78),
}),
Version: types.StringValue("version"),
Region: types.StringValue(testRegion),
},
true,
},
{
"nil_response",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
},
nil,
&flavorModel{},
&storageModel{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
},
&postgresflex.GetInstanceResponse{},
&flavorModel{},
&storageModel{},
testRegion,
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state, tt.flavor, tt.storage, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
inputAcl []string
inputFlavor *flavorModel
inputStorage *storageModel
inputEncryption *encryptionModel
inputNetwork *networkModel
expected *postgresflex.CreateInstanceRequestPayload
isValid bool
}{
{
"default_values",
&Model{},
[]string{},
&flavorModel{},
&storageModel{},
&encryptionModel{},
&networkModel{},
&postgresflex.CreateInstanceRequestPayload{
Acl: &[]string{},
Storage: postgresflex.CreateInstanceRequestPayloadGetStorageAttributeType(&postgresflex.Storage{}),
},
true,
},
{
"simple_values",
&Model{
BackupSchedule: types.StringValue("schedule"),
Name: types.StringValue("name"),
Replicas: types.Int64Value(12),
Version: types.StringValue("version"),
},
[]string{
"ip_1",
"ip_2",
},
&flavorModel{
Id: types.StringValue("flavor_id"),
},
&storageModel{
Class: types.StringValue("class"),
Size: types.Int64Value(34),
},
&encryptionModel{},
&networkModel{},
&postgresflex.CreateInstanceRequestPayload{
Acl: &[]string{
"ip_1",
"ip_2",
},
BackupSchedule: utils.Ptr("schedule"),
FlavorId: utils.Ptr("flavor_id"),
Name: utils.Ptr("name"),
Replicas: postgresflex.CreateInstanceRequestPayloadGetReplicasAttributeType(utils.Ptr(int32(56))),
Storage: postgresflex.CreateInstanceRequestPayloadGetStorageAttributeType(&postgresflex.Storage{
PerformanceClass: utils.Ptr("class"),
Size: utils.Ptr(int64(34)),
}),
Version: utils.Ptr("version"),
},
true,
},
{
"null_fields_and_int_conversions",
&Model{
BackupSchedule: types.StringNull(),
Name: types.StringNull(),
Replicas: types.Int64Value(2123456789),
Version: types.StringNull(),
},
[]string{
"",
},
&flavorModel{
Id: types.StringNull(),
},
&storageModel{
Class: types.StringNull(),
Size: types.Int64Null(),
},
&encryptionModel{},
&networkModel{},
&postgresflex.CreateInstanceRequestPayload{
Acl: &[]string{
"",
},
BackupSchedule: nil,
FlavorId: nil,
Name: nil,
Replicas: postgresflex.CreateInstanceRequestPayloadGetReplicasAttributeType(utils.Ptr(int32(2123456789))),
Storage: postgresflex.CreateInstanceRequestPayloadGetStorageAttributeType(&postgresflex.Storage{
PerformanceClass: nil,
Size: nil,
}),
Version: nil,
},
true,
},
{
"nil_model",
nil,
[]string{},
&flavorModel{},
&storageModel{},
&encryptionModel{},
&networkModel{},
nil,
false,
},
{
"nil_acl",
&Model{},
nil,
&flavorModel{},
&storageModel{},
&encryptionModel{},
&networkModel{},
nil,
false,
},
{
"nil_flavor",
&Model{},
[]string{},
nil,
&storageModel{},
&encryptionModel{},
&networkModel{},
nil,
false,
},
{
"nil_storage",
&Model{},
[]string{},
&flavorModel{},
nil,
&encryptionModel{},
&networkModel{},
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage, tt.inputEncryption, tt.inputNetwork)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
//func TestToUpdatePayload(t *testing.T) {
// tests := []struct {
// description string
// input *Model
// inputAcl []string
// inputFlavor *flavorModel
// inputStorage *storageModel
// expected *postgresflex.PartialUpdateInstancePayload
// isValid bool
// }{
// {
// "default_values",
// &Model{},
// []string{},
// &flavorModel{},
// &storageModel{},
// &postgresflex.PartialUpdateInstancePayload{
// Acl: &postgresflex.ACL{
// Items: &[]string{},
// },
// Storage: &postgresflex.Storage{},
// },
// true,
// },
// {
// "simple_values",
// &Model{
// BackupSchedule: types.StringValue("schedule"),
// Name: types.StringValue("name"),
// Replicas: types.Int64Value(12),
// Version: types.StringValue("version"),
// },
// []string{
// "ip_1",
// "ip_2",
// },
// &flavorModel{
// Id: types.StringValue("flavor_id"),
// },
// &storageModel{
// Class: types.StringValue("class"),
// Size: types.Int64Value(34),
// },
// &postgresflex.PartialUpdateInstancePayload{
// Acl: &postgresflex.ACL{
// Items: &[]string{
// "ip_1",
// "ip_2",
// },
// },
// BackupSchedule: utils.Ptr("schedule"),
// FlavorId: utils.Ptr("flavor_id"),
// Name: utils.Ptr("name"),
// Replicas: utils.Ptr(int64(12)),
// Storage: &postgresflex.Storage{
// Class: utils.Ptr("class"),
// Size: utils.Ptr(int64(34)),
// },
// Version: utils.Ptr("version"),
// },
// true,
// },
// {
// "null_fields_and_int_conversions",
// &Model{
// BackupSchedule: types.StringNull(),
// Name: types.StringNull(),
// Replicas: types.Int64Value(2123456789),
// Version: types.StringNull(),
// },
// []string{
// "",
// },
// &flavorModel{
// Id: types.StringNull(),
// },
// &storageModel{
// Class: types.StringNull(),
// Size: types.Int64Null(),
// },
// &postgresflex.PartialUpdateInstancePayload{
// Acl: &postgresflex.ACL{
// Items: &[]string{
// "",
// },
// },
// BackupSchedule: nil,
// FlavorId: nil,
// Name: nil,
// Replicas: utils.Ptr(int64(2123456789)),
// Storage: &postgresflex.Storage{
// Class: nil,
// Size: nil,
// },
// Version: nil,
// },
// true,
// },
// {
// "nil_model",
// nil,
// []string{},
// &flavorModel{},
// &storageModel{},
// nil,
// false,
// },
// {
// "nil_acl",
// &Model{},
// nil,
// &flavorModel{},
// &storageModel{},
// nil,
// false,
// },
// {
// "nil_flavor",
// &Model{},
// []string{},
// nil,
// &storageModel{},
// nil,
// false,
// },
// {
// "nil_storage",
// &Model{},
// []string{},
// &flavorModel{},
// nil,
// nil,
// false,
// },
// }
// for _, tt := range tests {
// t.Run(tt.description, func(t *testing.T) {
// output, err := toUpdatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage)
// if !tt.isValid && err == nil {
// t.Fatalf("Should have failed")
// }
// if tt.isValid && err != nil {
// t.Fatalf("Should not have failed: %v", err)
// }
// if tt.isValid {
// diff := cmp.Diff(output, tt.expected)
// if diff != "" {
// t.Fatalf("Data does not match: %s", diff)
// }
// }
// })
// }
//}
//
//func TestLoadFlavorId(t *testing.T) {
// tests := []struct {
// description string
// inputFlavor *flavorModel
// mockedResp *postgresflex.ListFlavorsResponse
// expected *flavorModel
// getFlavorsFails bool
// isValid bool
// }{
// {
// "ok_flavor",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &postgresflex.ListFlavorsResponse{
// Flavors: &[]postgresflex.Flavor{
// {
// Id: utils.Ptr("fid-1"),
// Cpu: utils.Ptr(int64(2)),
// Description: utils.Ptr("description"),
// Memory: utils.Ptr(int64(8)),
// },
// },
// },
// &flavorModel{
// Id: types.StringValue("fid-1"),
// Description: types.StringValue("description"),
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// false,
// true,
// },
// {
// "ok_flavor_2",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &postgresflex.ListFlavorsResponse{
// Flavors: &[]postgresflex.Flavor{
// {
// Id: utils.Ptr("fid-1"),
// Cpu: utils.Ptr(int64(2)),
// Description: utils.Ptr("description"),
// Memory: utils.Ptr(int64(8)),
// },
// {
// Id: utils.Ptr("fid-2"),
// Cpu: utils.Ptr(int64(1)),
// Description: utils.Ptr("description"),
// Memory: utils.Ptr(int64(4)),
// },
// },
// },
// &flavorModel{
// Id: types.StringValue("fid-1"),
// Description: types.StringValue("description"),
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// false,
// true,
// },
// {
// "no_matching_flavor",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &postgresflex.ListFlavorsResponse{
// Flavors: &[]postgresflex.Flavor{
// {
// Id: utils.Ptr("fid-1"),
// Cpu: utils.Ptr(int64(1)),
// Description: utils.Ptr("description"),
// Memory: utils.Ptr(int64(8)),
// },
// {
// Id: utils.Ptr("fid-2"),
// Cpu: utils.Ptr(int64(1)),
// Description: utils.Ptr("description"),
// Memory: utils.Ptr(int64(4)),
// },
// },
// },
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// false,
// false,
// },
// {
// "nil_response",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &postgresflex.ListFlavorsResponse{},
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// false,
// false,
// },
// {
// "error_response",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &postgresflex.ListFlavorsResponse{},
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// true,
// false,
// },
// }
// for _, tt := range tests {
// t.Run(tt.description, func(t *testing.T) {
// client := &postgresFlexClientMocked{
// returnError: tt.getFlavorsFails,
// getFlavorsResp: tt.mockedResp,
// }
// model := &Model{
// ProjectId: types.StringValue("pid"),
// }
// flavorModel := &flavorModel{
// CPU: tt.inputFlavor.CPU,
// RAM: tt.inputFlavor.RAM,
// }
// err := loadFlavorId(context.Background(), client, model, flavorModel)
// if !tt.isValid && err == nil {
// t.Fatalf("Should have failed")
// }
// if tt.isValid && err != nil {
// t.Fatalf("Should not have failed: %v", err)
// }
// if tt.isValid {
// diff := cmp.Diff(flavorModel, tt.expected)
// if diff != "" {
// t.Fatalf("Data does not match: %s", diff)
// }
// }
// })
// }
//}

View file

@ -1,87 +0,0 @@
// Copyright (c) STACKIT
package postgresflex
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)
type useStateForUnknownIfFlavorUnchangedModifier struct {
Req resource.SchemaRequest
}
// UseStateForUnknownIfFlavorUnchanged returns a plan modifier similar to UseStateForUnknown
// if the RAM and CPU values are not changed in the plan. Otherwise, the plan modifier does nothing.
func UseStateForUnknownIfFlavorUnchanged(req resource.SchemaRequest) planmodifier.String {
return useStateForUnknownIfFlavorUnchangedModifier{
Req: req,
}
}
func (m useStateForUnknownIfFlavorUnchangedModifier) Description(context.Context) string {
return "UseStateForUnknownIfFlavorUnchanged returns a plan modifier similar to UseStateForUnknown if the RAM and CPU values are not changed in the plan. Otherwise, the plan modifier does nothing."
}
func (m useStateForUnknownIfFlavorUnchangedModifier) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}
func (m useStateForUnknownIfFlavorUnchangedModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { // nolint:gocritic // function signature required by Terraform
// Do nothing if there is no state value.
if req.StateValue.IsNull() {
return
}
// Do nothing if there is a known planned value.
if !req.PlanValue.IsUnknown() {
return
}
// Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up.
if req.ConfigValue.IsUnknown() {
return
}
// The above checks are taken from the UseStateForUnknown plan modifier implementation
// (https://github.com/hashicorp/terraform-plugin-framework/blob/main/resource/schema/stringplanmodifier/use_state_for_unknown.go#L38)
var stateModel Model
diags := req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
var stateFlavor = &flavorModel{}
if !(stateModel.Flavor.IsNull() || stateModel.Flavor.IsUnknown()) {
diags = stateModel.Flavor.As(ctx, stateFlavor, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var planModel Model
diags = req.Plan.Get(ctx, &planModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
var planFlavor = &flavorModel{}
if !(planModel.Flavor.IsNull() || planModel.Flavor.IsUnknown()) {
diags = planModel.Flavor.As(ctx, planFlavor, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
if planFlavor.CPU == stateFlavor.CPU && planFlavor.RAM == stateFlavor.RAM {
resp.PlanValue = req.StateValue
}
}

View file

@ -0,0 +1,52 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package postgresflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
)
func RolesDataSourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"instance_id": schema.StringAttribute{
Required: true,
Description: "The ID of the instance.",
MarkdownDescription: "The ID of the instance.",
},
"project_id": schema.StringAttribute{
Required: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Required: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
"roles": schema.ListAttribute{
ElementType: types.StringType,
Computed: true,
Description: "List of all role names available in the instance",
MarkdownDescription: "List of all role names available in the instance",
},
},
}
}
type RolesModel struct {
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
Roles types.List `tfsdk:"roles"`
}

View file

@ -0,0 +1,54 @@
provider "stackitprivatepreview" {
default_region = "{{ .Region }}"
service_account_key_path = "{{ .ServiceAccountFilePath }}"
}
resource "stackitprivatepreview_postgresflexalpha_instance" "{{ .TfName }}" {
project_id = "{{ .ProjectID }}"
name = "{{ .Name }}"
backup_schedule = "{{ .BackupSchedule }}"
retention_days = {{ .RetentionDays }}
flavor_id = "{{ .FlavorID }}"
replicas = {{ .Replicas }}
storage = {
performance_class = "{{ .PerformanceClass }}"
size = {{ .Size }}
}
{{ if .UseEncryption }}
encryption = {
kek_key_id = "{{ .KekKeyID }}"
kek_key_ring_id = "{{ .KekKeyRingID }}"
kek_key_version = {{ .KekKeyVersion }}
service_account = "{{ .KekServiceAccount }}"
}
{{ end }}
network = {
acl = ["{{ .ACLString }}"]
access_scope = "{{ .AccessScope }}"
}
version = {{ .Version }}
}
{{ if .Users }}
{{ $tfName := .TfName }}
{{ range $user := .Users }}
resource "stackitprivatepreview_postgresflexalpha_user" "{{ $user.Name }}" {
project_id = "{{ $user.ProjectID }}"
instance_id = stackitprivatepreview_postgresflexalpha_instance.{{ $tfName }}.instance_id
name = "{{ $user.Name }}"
roles = [{{ range $i, $v := $user.Roles }}{{if $i}},{{end}}"{{$v}}"{{end}}]
}
{{ end }}
{{ end }}
{{ if .Databases }}
{{ $tfName := .TfName }}
{{ range $db := .Databases }}
resource "stackitprivatepreview_postgresflexalpha_database" "{{ $db.Name }}" {
project_id = "{{ $db.ProjectID }}"
instance_id = stackitprivatepreview_postgresflexalpha_instance.{{ $tfName }}.instance_id
name = "{{ $db.Name }}"
owner = stackitprivatepreview_postgresflexalpha_user.{{ $db.Owner }}.name
}
{{ end }}
{{ end }}

View file

@ -1,26 +1,25 @@
// Copyright (c) STACKIT
package postgresflexa
package postgresflexalpha
import (
"context"
"fmt"
"math"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"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"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
postgresflexalpha "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/user/datasources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
"github.com/hashicorp/terraform-plugin-log/tflog"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
// Ensure the implementation satisfies the expected interfaces.
@ -28,36 +27,38 @@ var (
_ datasource.DataSource = &userDataSource{}
)
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
UserId types.String `tfsdk:"user_id"`
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Username types.String `tfsdk:"username"`
Roles types.Set `tfsdk:"roles"`
Host types.String `tfsdk:"host"`
Port types.Int64 `tfsdk:"port"`
Region types.String `tfsdk:"region"`
}
// NewUserDataSource is a helper function to simplify the provider implementation.
func NewUserDataSource() datasource.DataSource {
return &userDataSource{}
}
// dataSourceModel maps the data source schema data.
type dataSourceModel struct {
postgresflexalpha.UserModel
TerraformID types.String `tfsdk:"id"`
}
// userDataSource is the data source implementation.
type userDataSource struct {
client *postgresflex.APIClient
client *v3alpha1api.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (r *userDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflex_user"
func (r *userDataSource) Metadata(
_ context.Context,
req datasource.MetadataRequest,
resp *datasource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_user"
}
// Configure adds the provider configured client to the data source.
func (r *userDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
func (r *userDataSource) Configure(
ctx context.Context,
req datasource.ConfigureRequest,
resp *datasource.ConfigureResponse,
) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
@ -73,71 +74,25 @@ func (r *userDataSource) Configure(ctx context.Context, req datasource.Configure
}
// Schema defines the schema for the data source.
func (r *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex user data source schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`instance_id`,`user_id`\".",
"user_id": "User ID.",
"instance_id": "ID of the PostgresFlex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"region": "The resource region. If not defined, the provider region is used.",
func (r *userDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
s := postgresflexalpha.UserDataSourceSchema(ctx)
s.Attributes["id"] = schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \\\"`project_id`,`region`,`instance_id`," +
"`user_id`\\\".\",",
Optional: true,
Computed: true,
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"user_id": schema.StringAttribute{
Description: descriptions["user_id"],
Required: true,
Validators: []validator.String{
validate.NoSeparator(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"username": schema.StringAttribute{
Computed: true,
},
"roles": schema.SetAttribute{
ElementType: types.StringType,
Computed: true,
},
"host": schema.StringAttribute{
Computed: true,
},
"port": schema.Int64Attribute{
Computed: true,
},
"region": schema.StringAttribute{
// the region cannot be found automatically, so it has to be passed
Optional: true,
Description: descriptions["region"],
},
},
}
resp.Schema = s
}
// Read refreshes the Terraform state with the latest data.
func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DataSourceModel
func (r *userDataSource) Read(
ctx context.Context,
req datasource.ReadRequest,
resp *datasource.ReadResponse,
) { // nolint:gocritic // function signature required by Terraform
var model dataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
@ -146,27 +101,24 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
userId := model.UserId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "user_id", userId)
ctx = tflog.SetField(ctx, "region", region)
projectID := model.ProjectId.ValueString()
instanceID := model.InstanceId.ValueString()
userID64 := model.UserId.ValueInt64()
if userID64 > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error in type conversion", "int value too large (userId)")
return
}
userID := int32(userID64) // nolint:gosec // check is performed above
recordSetResp, err := r.client.GetUser(ctx, projectId, region, instanceId, userId).Execute()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectID)
ctx = tflog.SetField(ctx, "instance_id", instanceID)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "user_id", userID)
recordSetResp, err := r.client.DefaultAPI.GetUserRequest(ctx, projectID, region, instanceID, userID).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading user",
fmt.Sprintf("User with ID %q or instance with ID %q does not exist in project %q.", userId, instanceId, projectId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId),
},
)
handleReadError(ctx, &diags, err, projectID, instanceID, userID)
resp.State.RemoveResource(ctx)
return
}
@ -176,7 +128,12 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
// Map response body to schema and populate Computed attribute values
err = mapDataSourceFields(recordSetResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Processing API payload: %v", err))
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading user",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
@ -189,44 +146,38 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
tflog.Info(ctx, "Postgres Flex user read")
}
func mapDataSourceFields(userResp *postgresflex.GetUserResponse, model *DataSourceModel, region string) error {
if userResp == nil || userResp.Item == nil {
return fmt.Errorf("response is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
user := userResp.Item
var userId string
if model.UserId.ValueString() != "" {
userId = model.UserId.ValueString()
} else if user.Id != nil {
userId = *user.Id
} else {
return fmt.Errorf("user id not present")
}
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), userId,
// handleReadError centralizes API error handling for the Read operation.
func handleReadError(
ctx context.Context,
diags *diag.Diagnostics,
err error,
projectID, instanceID string,
userID int32,
) {
utils.LogError(
ctx,
diags,
err,
"Reading user",
fmt.Sprintf(
"User with ID %q or instance with ID %q does not exist in project %q.",
userID,
instanceID,
projectID,
),
map[int]string{
http.StatusBadRequest: fmt.Sprintf(
"Invalid user request parameters for project %q and instance %q.",
projectID,
instanceID,
),
http.StatusNotFound: fmt.Sprintf(
"User, instance %q, or project %q or user %q not found.",
instanceID,
projectID,
userID,
),
http.StatusForbidden: fmt.Sprintf("Forbidden access to project %q.", projectID),
},
)
model.UserId = types.StringValue(userId)
model.Username = types.StringPointerValue(user.Username)
if user.Roles == nil {
model.Roles = types.SetNull(types.StringType)
} else {
roles := []attr.Value{}
for _, role := range *user.Roles {
roles = append(roles, types.StringValue(role))
}
rolesSet, diags := types.SetValue(types.StringType, roles)
if diags.HasError() {
return fmt.Errorf("failed to map roles: %w", core.DiagsToError(diags))
}
model.Roles = rolesSet
}
model.Host = types.StringPointerValue(user.Host)
model.Port = types.Int64PointerValue(user.Port)
model.Region = types.StringValue(region)
return nil
}

View file

@ -1,146 +0,0 @@
// Copyright (c) STACKIT
package postgresflexa
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
func TestMapDataSourceFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.GetUserResponse
region string
expected DataSourceModel
isValid bool
}{
{
"default_values",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringNull(),
Roles: types.SetNull(types.StringType),
Host: types.StringNull(),
Port: types.Int64Null(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{
Roles: &[]string{
"role_1",
"role_2",
"",
},
Username: utils.Ptr("username"),
Host: utils.Ptr("host"),
Port: utils.Ptr(int64(1234)),
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringValue("username"),
Roles: types.SetValueMust(types.StringType, []attr.Value{
types.StringValue("role_1"),
types.StringValue("role_2"),
types.StringValue(""),
}),
Host: types.StringValue("host"),
Port: types.Int64Value(1234),
Region: types.StringValue(testRegion),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{
Id: utils.Ptr("uid"),
Roles: &[]string{},
Username: nil,
Host: nil,
Port: utils.Ptr(int64(2123456789)),
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringNull(),
Roles: types.SetValueMust(types.StringType, []attr.Value{}),
Host: types.StringNull(),
Port: types.Int64Value(2123456789),
Region: types.StringValue(testRegion),
},
true,
},
{
"nil_response",
nil,
testRegion,
DataSourceModel{},
false,
},
{
"nil_response_2",
&postgresflex.GetUserResponse{},
testRegion,
DataSourceModel{},
false,
},
{
"no_resource_id",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{},
},
testRegion,
DataSourceModel{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
state := &DataSourceModel{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
UserId: tt.expected.UserId,
}
err := mapDataSourceFields(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(state, &tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,76 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package postgresflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
)
func UserDataSourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"tf_original_api_id": schema.Int64Attribute{
Computed: true,
Description: "The ID of the user.",
MarkdownDescription: "The ID of the user.",
},
"instance_id": schema.StringAttribute{
Required: true,
Description: "The ID of the instance.",
MarkdownDescription: "The ID of the instance.",
},
"name": schema.StringAttribute{
Computed: true,
Description: "The name of the user.",
MarkdownDescription: "The name of the user.",
},
"project_id": schema.StringAttribute{
Required: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Required: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
"roles": schema.ListAttribute{
ElementType: types.StringType,
Computed: true,
Description: "A list of user roles.",
MarkdownDescription: "A list of user roles.",
},
"status": schema.StringAttribute{
Computed: true,
Description: "The current status of the user.",
MarkdownDescription: "The current status of the user.",
},
"user_id": schema.Int64Attribute{
Required: true,
Description: "The ID of the user.",
MarkdownDescription: "The ID of the user.",
},
},
}
}
type UserModel struct {
Id types.Int64 `tfsdk:"tf_original_api_id"`
InstanceId types.String `tfsdk:"instance_id"`
Name types.String `tfsdk:"name"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
Roles types.List `tfsdk:"roles"`
Status types.String `tfsdk:"status"`
UserId types.Int64 `tfsdk:"user_id"`
}

View file

@ -0,0 +1,139 @@
package postgresflexalpha
import (
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
// mapDataSourceFields maps API response to data source model, preserving existing ID.
func mapDataSourceFields(userResp *v3alpha1api.GetUserResponse, model *dataSourceModel, region string) error {
if userResp == nil {
return fmt.Errorf("response is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
user := userResp
var userID int64
if model.UserId.ValueInt64() == 0 {
return fmt.Errorf("user id not present")
}
userID = model.UserId.ValueInt64()
model.TerraformID = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), strconv.FormatInt(userID, 10),
)
model.UserId = types.Int64Value(userID)
model.Name = types.StringValue(user.GetName())
if user.Roles == nil {
model.Roles = types.List(types.SetNull(types.StringType))
} else {
var roles []attr.Value
for _, role := range user.Roles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)
if diags.HasError() {
return fmt.Errorf("failed to map roles: %w", core.DiagsToError(diags))
}
model.Roles = types.List(rolesSet)
}
model.Id = types.Int64Value(userID)
model.Region = types.StringValue(region)
model.Status = types.StringValue(user.GetStatus())
return nil
}
// toPayloadRoles converts a string slice to the API's role type.
func toPayloadRoles(roles []string) []v3alpha1api.UserRole {
var userRoles = make([]v3alpha1api.UserRole, 0, len(roles))
for _, role := range roles {
userRoles = append(userRoles, v3alpha1api.UserRole(role))
}
return userRoles
}
// toUpdatePayload creates an API update payload from the resource model.
func toUpdatePayload(model *resourceModel, roles []string) (
*v3alpha1api.UpdateUserRequestPayload,
error,
) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if roles == nil {
return nil, fmt.Errorf("nil roles")
}
return &v3alpha1api.UpdateUserRequestPayload{
Name: model.Name.ValueStringPointer(),
Roles: toPayloadRoles(roles),
}, nil
}
// toCreatePayload creates an API create payload from the resource model.
func toCreatePayload(model *resourceModel, roles []string) (*v3alpha1api.CreateUserRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if roles == nil {
return nil, fmt.Errorf("nil roles")
}
return &v3alpha1api.CreateUserRequestPayload{
Roles: toPayloadRoles(roles),
Name: model.Name.ValueString(),
}, nil
}
// mapResourceFields maps API response to the resource model, preserving existing ID.
func mapResourceFields(userResp *v3alpha1api.GetUserResponse, model *resourceModel, region string) error {
if userResp == nil {
return fmt.Errorf("response is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
user := userResp
var userID int64
if !model.UserId.IsNull() && !model.UserId.IsUnknown() && model.UserId.ValueInt64() != 0 {
userID = model.UserId.ValueInt64()
} else if user.Id != 0 {
userID = int64(user.Id)
} else {
return fmt.Errorf("user id not present")
}
model.Id = types.Int64Value(userID)
model.UserId = types.Int64Value(userID)
model.Name = types.StringValue(user.Name)
if user.Roles == nil {
model.Roles = types.List(types.SetNull(types.StringType))
} else {
var roles []attr.Value
for _, role := range user.Roles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)
if diags.HasError() {
return fmt.Errorf("failed to map roles: %w", core.DiagsToError(diags))
}
model.Roles = types.List(rolesSet)
}
model.Region = types.StringValue(region)
model.Status = types.StringValue(user.Status)
return nil
}

View file

@ -0,0 +1,573 @@
package postgresflexalpha
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
data "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/user/datasources_gen"
)
func TestMapDataSourceFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.GetUserResponse
region string
expected dataSourceModel
isValid bool
}{
{
"default_values",
&postgresflex.GetUserResponse{},
testRegion,
dataSourceModel{
UserModel: data.UserModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Roles: types.List(types.SetNull(types.StringType)),
Status: types.StringValue(""),
Region: types.StringValue(testRegion),
},
TerraformID: types.StringValue("pid,region,iid,1"),
},
true,
},
{
"simple_values",
&postgresflex.GetUserResponse{
Roles: []postgresflex.UserRole{
"role_1",
"role_2",
"",
},
Name: "username",
},
testRegion,
dataSourceModel{
UserModel: data.UserModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("username"),
Roles: types.List(
types.SetValueMust(
types.StringType, []attr.Value{
types.StringValue("role_1"),
types.StringValue("role_2"),
types.StringValue(""),
},
),
),
Region: types.StringValue(testRegion),
Status: types.StringValue(""),
},
TerraformID: types.StringValue("pid,region,iid,1"),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.GetUserResponse{
Id: int32(1),
Roles: []postgresflex.UserRole{},
Name: "",
Status: "status",
},
testRegion,
dataSourceModel{
UserModel: data.UserModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Roles: types.List(types.SetValueMust(types.StringType, []attr.Value{})),
Region: types.StringValue(testRegion),
Status: types.StringValue("status"),
},
TerraformID: types.StringValue("pid,region,iid,1"),
},
true,
},
{
"nil_response",
nil,
testRegion,
dataSourceModel{},
false,
},
{
"nil_response_2",
&postgresflex.GetUserResponse{},
testRegion,
dataSourceModel{},
false,
},
{
"no_resource_id",
&postgresflex.GetUserResponse{},
testRegion,
dataSourceModel{},
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
state := &dataSourceModel{
UserModel: data.UserModel{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
UserId: tt.expected.UserId,
},
}
err := mapDataSourceFields(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(state, &tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
},
)
}
}
func TestMapFieldsCreate(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.GetUserResponse
region string
expected resourceModel
isValid bool
}{
{
"default_values",
&postgresflex.GetUserResponse{
Id: int32(1),
},
testRegion,
resourceModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Roles: types.List(types.SetNull(types.StringType)),
Password: types.StringNull(),
Region: types.StringValue(testRegion),
Status: types.StringValue(""),
//ConnectionString: types.StringNull(),
},
true,
},
{
"simple_values",
&postgresflex.GetUserResponse{
Id: int32(1),
Name: "username",
Status: "status",
},
testRegion,
resourceModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("username"),
Roles: types.List(types.SetNull(types.StringType)),
Password: types.StringNull(),
Region: types.StringValue(testRegion),
Status: types.StringValue("status"),
//ConnectionString: types.StringNull(),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.GetUserResponse{
Id: int32(1),
Name: "",
Status: "",
},
testRegion,
resourceModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Roles: types.List(types.SetNull(types.StringType)),
Password: types.StringNull(),
Region: types.StringValue(testRegion),
Status: types.StringValue(""),
//ConnectionString: types.StringNull(),
},
true,
},
{
"nil_response",
nil,
testRegion,
resourceModel{},
false,
},
{
"nil_response_2",
&postgresflex.GetUserResponse{},
testRegion,
resourceModel{},
false,
},
{
"no_resource_id",
&postgresflex.GetUserResponse{},
testRegion,
resourceModel{},
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
state := &resourceModel{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
}
err := mapResourceFields(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(&tt.expected, state)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
},
)
}
}
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.GetUserResponse
region string
expected resourceModel
isValid bool
}{
{
"default_values",
&postgresflex.GetUserResponse{
Id: int32(1),
},
testRegion,
resourceModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(int64(1)),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Roles: types.List(types.SetNull(types.StringType)),
Region: types.StringValue(testRegion),
Status: types.StringValue(""),
//ConnectionString: types.StringNull(),
},
true,
},
{
"simple_values",
&postgresflex.GetUserResponse{
Id: int32(1),
Roles: []postgresflex.UserRole{
"role_1",
"role_2",
"",
},
Name: "username",
},
testRegion,
resourceModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("username"),
Roles: types.List(
types.SetValueMust(
types.StringType, []attr.Value{
types.StringValue("role_1"),
types.StringValue("role_2"),
types.StringValue(""),
},
),
),
Region: types.StringValue(testRegion),
Status: types.StringValue(""),
//ConnectionString: types.StringNull(),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.GetUserResponse{
Id: int32(1),
Name: "",
},
testRegion,
resourceModel{
Id: types.Int64Value(1),
UserId: types.Int64Value(1),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Roles: types.List(types.SetNull(types.StringType)),
Region: types.StringValue(testRegion),
Status: types.StringValue(""),
//ConnectionString: types.StringNull(),
},
true,
},
{
"nil_response",
nil,
testRegion,
resourceModel{},
false,
},
{
"nil_response_2",
&postgresflex.GetUserResponse{},
testRegion,
resourceModel{},
false,
},
{
"no_resource_id",
&postgresflex.GetUserResponse{},
testRegion,
resourceModel{},
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
state := &resourceModel{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
}
err := mapResourceFields(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(state, &tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
},
)
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *resourceModel
inputRoles []string
expected *postgresflex.CreateUserRequestPayload
isValid bool
}{
{
"default_values",
&resourceModel{},
[]string{},
&postgresflex.CreateUserRequestPayload{
Name: "",
Roles: []postgresflex.UserRole{},
},
true,
},
{
"simple_values",
&resourceModel{
Name: types.StringValue("username"),
},
[]string{
"role_1",
"role_2",
},
&postgresflex.CreateUserRequestPayload{
Name: "username",
Roles: []postgresflex.UserRole{
"role_1",
"role_2",
},
},
true,
},
{
"null_fields_and_int_conversions",
&resourceModel{
Name: types.StringNull(),
},
[]string{
"",
},
&postgresflex.CreateUserRequestPayload{
Roles: []postgresflex.UserRole{
"",
},
Name: "",
},
true,
},
{
"nil_model",
nil,
[]string{},
nil,
false,
},
{
"nil_roles",
&resourceModel{},
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input, tt.inputRoles)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
},
)
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *resourceModel
inputRoles []string
expected *postgresflex.UpdateUserRequestPayload
isValid bool
}{
{
"default_values",
&resourceModel{},
[]string{},
&postgresflex.UpdateUserRequestPayload{
Roles: []postgresflex.UserRole{},
},
true,
},
{
"default_values",
&resourceModel{
Name: types.StringValue("username"),
},
[]string{
"role_1",
"role_2",
},
&postgresflex.UpdateUserRequestPayload{
Name: utils.Ptr("username"),
Roles: []postgresflex.UserRole{
"role_1",
"role_2",
},
},
true,
},
{
"null_fields_and_int_conversions",
&resourceModel{
Name: types.StringNull(),
},
[]string{
"",
},
&postgresflex.UpdateUserRequestPayload{
Roles: []postgresflex.UserRole{
"",
},
},
true,
},
{
"nil_model",
nil,
[]string{},
nil,
false,
},
{
"nil_roles",
&resourceModel{},
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
output, err := toUpdatePayload(tt.input, tt.inputRoles)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
},
)
}
}

View file

@ -0,0 +1,64 @@
fields:
- name: 'id'
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'user_id'
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'instance_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'project_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'name'
modifiers:
- 'UseStateForUnknown'
- name: 'roles'
modifiers:
- 'UseStateForUnknown'
- name: 'password'
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'
- name: 'host'
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'
- name: 'port'
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'
- name: 'region'
modifiers:
- 'RequiresReplace'
- 'RequiresReplace'
- name: 'status'
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'
- name: 'connection_string'
modifiers:
- 'RequiresReplace'
- 'UseStateForUnknown'

File diff suppressed because it is too large Load diff

View file

@ -1,472 +0,0 @@
// Copyright (c) STACKIT
package postgresflexa
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
func TestMapFieldsCreate(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.CreateUserResponse
region string
expected Model
isValid bool
}{
{
"default_values",
&postgresflex.CreateUserResponse{
Item: &postgresflex.User{
Id: utils.Ptr("uid"),
Password: utils.Ptr(""),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringNull(),
Roles: types.SetNull(types.StringType),
Password: types.StringValue(""),
Host: types.StringNull(),
Port: types.Int64Null(),
Uri: types.StringNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
&postgresflex.CreateUserResponse{
Item: &postgresflex.User{
Id: utils.Ptr("uid"),
Roles: &[]string{
"role_1",
"role_2",
"",
},
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
Host: utils.Ptr("host"),
Port: utils.Ptr(int64(1234)),
Uri: utils.Ptr("uri"),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringValue("username"),
Roles: types.SetValueMust(types.StringType, []attr.Value{
types.StringValue("role_1"),
types.StringValue("role_2"),
types.StringValue(""),
}),
Password: types.StringValue("password"),
Host: types.StringValue("host"),
Port: types.Int64Value(1234),
Uri: types.StringValue("uri"),
Region: types.StringValue(testRegion),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.CreateUserResponse{
Item: &postgresflex.User{
Id: utils.Ptr("uid"),
Roles: &[]string{},
Username: nil,
Password: utils.Ptr(""),
Host: nil,
Port: utils.Ptr(int64(2123456789)),
Uri: nil,
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringNull(),
Roles: types.SetValueMust(types.StringType, []attr.Value{}),
Password: types.StringValue(""),
Host: types.StringNull(),
Port: types.Int64Value(2123456789),
Uri: types.StringNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"nil_response",
nil,
testRegion,
Model{},
false,
},
{
"nil_response_2",
&postgresflex.CreateUserResponse{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
&postgresflex.CreateUserResponse{
Item: &postgresflex.User{},
},
testRegion,
Model{},
false,
},
{
"no_password",
&postgresflex.CreateUserResponse{
Item: &postgresflex.User{
Id: utils.Ptr("uid"),
},
},
testRegion,
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
state := &Model{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
}
err := mapFieldsCreate(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(state, &tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.GetUserResponse
region string
expected Model
isValid bool
}{
{
"default_values",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringNull(),
Roles: types.SetNull(types.StringType),
Host: types.StringNull(),
Port: types.Int64Null(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{
Roles: &[]string{
"role_1",
"role_2",
"",
},
Username: utils.Ptr("username"),
Host: utils.Ptr("host"),
Port: utils.Ptr(int64(1234)),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringValue("username"),
Roles: types.SetValueMust(types.StringType, []attr.Value{
types.StringValue("role_1"),
types.StringValue("role_2"),
types.StringValue(""),
}),
Host: types.StringValue("host"),
Port: types.Int64Value(1234),
Region: types.StringValue(testRegion),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{
Id: utils.Ptr("uid"),
Roles: &[]string{},
Username: nil,
Host: nil,
Port: utils.Ptr(int64(2123456789)),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,uid"),
UserId: types.StringValue("uid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Username: types.StringNull(),
Roles: types.SetValueMust(types.StringType, []attr.Value{}),
Host: types.StringNull(),
Port: types.Int64Value(2123456789),
Region: types.StringValue(testRegion),
},
true,
},
{
"nil_response",
nil,
testRegion,
Model{},
false,
},
{
"nil_response_2",
&postgresflex.GetUserResponse{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
&postgresflex.GetUserResponse{
Item: &postgresflex.UserResponse{},
},
testRegion,
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
state := &Model{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
UserId: tt.expected.UserId,
}
err := mapFields(tt.input, state, tt.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(state, &tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
inputRoles []string
expected *postgresflex.CreateUserPayload
isValid bool
}{
{
"default_values",
&Model{},
[]string{},
&postgresflex.CreateUserPayload{
Roles: &[]string{},
Username: nil,
},
true,
},
{
"default_values",
&Model{
Username: types.StringValue("username"),
},
[]string{
"role_1",
"role_2",
},
&postgresflex.CreateUserPayload{
Roles: &[]string{
"role_1",
"role_2",
},
Username: utils.Ptr("username"),
},
true,
},
{
"null_fields_and_int_conversions",
&Model{
Username: types.StringNull(),
},
[]string{
"",
},
&postgresflex.CreateUserPayload{
Roles: &[]string{
"",
},
Username: nil,
},
true,
},
{
"nil_model",
nil,
[]string{},
nil,
false,
},
{
"nil_roles",
&Model{},
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input, tt.inputRoles)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
inputRoles []string
expected *postgresflex.UpdateUserPayload
isValid bool
}{
{
"default_values",
&Model{},
[]string{},
&postgresflex.UpdateUserPayload{
Roles: &[]string{},
},
true,
},
{
"default_values",
&Model{
Username: types.StringValue("username"),
},
[]string{
"role_1",
"role_2",
},
&postgresflex.UpdateUserPayload{
Roles: &[]string{
"role_1",
"role_2",
},
},
true,
},
{
"null_fields_and_int_conversions",
&Model{
Username: types.StringNull(),
},
[]string{
"",
},
&postgresflex.UpdateUserPayload{
Roles: &[]string{
"",
},
},
true,
},
{
"nil_model",
nil,
[]string{},
nil,
false,
},
{
"nil_roles",
&Model{},
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(tt.input, tt.inputRoles)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,87 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package postgresflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
)
func UserResourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.Int64Attribute{
Computed: true,
Description: "The ID of the user.",
MarkdownDescription: "The ID of the user.",
},
"instance_id": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The ID of the instance.",
MarkdownDescription: "The ID of the instance.",
},
"name": schema.StringAttribute{
Required: true,
Description: "The name of the user.",
MarkdownDescription: "The name of the user.",
},
"password": schema.StringAttribute{
Computed: true,
Description: "The password for the user.",
MarkdownDescription: "The password for the user.",
},
"project_id": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
"roles": schema.ListAttribute{
ElementType: types.StringType,
Optional: true,
Computed: true,
Description: "A list containing the user roles for the instance.",
MarkdownDescription: "A list containing the user roles for the instance.",
},
"status": schema.StringAttribute{
Computed: true,
Description: "The current status of the user.",
MarkdownDescription: "The current status of the user.",
},
"user_id": schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "The ID of the user.",
MarkdownDescription: "The ID of the user.",
},
},
}
}
type UserModel struct {
Id types.Int64 `tfsdk:"id"`
InstanceId types.String `tfsdk:"instance_id"`
Name types.String `tfsdk:"name"`
Password types.String `tfsdk:"password"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
Roles types.List `tfsdk:"roles"`
Status types.String `tfsdk:"status"`
UserId types.Int64 `tfsdk:"user_id"`
}

View file

@ -8,10 +8,11 @@ import (
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/stackit-sdk-go/core/config"
postgresflex "github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *postgresflex.APIClient {

View file

@ -11,9 +11,11 @@ import (
"github.com/hashicorp/terraform-plugin-framework/diag"
sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3alpha1api"
)
const (
@ -36,7 +38,7 @@ func TestConfigureClient(t *testing.T) {
name string
args args
wantErr bool
expected *postgresflex.APIClient
expected *v3alpha1api.APIClient
}{
{
name: "default endpoint",
@ -45,8 +47,8 @@ func TestConfigureClient(t *testing.T) {
Version: testVersion,
},
},
expected: func() *postgresflex.APIClient {
apiClient, err := postgresflex.NewAPIClient(
expected: func() *v3alpha1api.APIClient {
apiClient, err := v3alpha1api.NewAPIClient(
config.WithRegion("eu01"),
utils.UserAgentConfigOption(testVersion),
)
@ -65,8 +67,8 @@ func TestConfigureClient(t *testing.T) {
PostgresFlexCustomEndpoint: testCustomEndpoint,
},
},
expected: func() *postgresflex.APIClient {
apiClient, err := postgresflex.NewAPIClient(
expected: func() *v3alpha1api.APIClient {
apiClient, err := v3alpha1api.NewAPIClient(
utils.UserAgentConfigOption(testVersion),
config.WithEndpoint(testCustomEndpoint),
)

View file

@ -0,0 +1,569 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package postgresflexalpha
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"strings"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
)
func VersionsDataSourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"project_id": schema.StringAttribute{
Required: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Required: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
"versions": schema.ListNestedAttribute{
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"beta": schema.BoolAttribute{
Computed: true,
Description: "Flag if the version is a beta version. If set the version may contain bugs and is not fully tested.",
MarkdownDescription: "Flag if the version is a beta version. If set the version may contain bugs and is not fully tested.",
},
"deprecated": schema.StringAttribute{
Computed: true,
Description: "Timestamp in RFC3339 format which says when the version will no longer be supported by STACKIT.",
MarkdownDescription: "Timestamp in RFC3339 format which says when the version will no longer be supported by STACKIT.",
},
"recommend": schema.BoolAttribute{
Computed: true,
Description: "Flag if the version is recommend by the STACKIT Team.",
MarkdownDescription: "Flag if the version is recommend by the STACKIT Team.",
},
"version": schema.StringAttribute{
Computed: true,
Description: "The postgres version used for the instance.",
MarkdownDescription: "The postgres version used for the instance.",
},
},
CustomType: VersionsType{
ObjectType: types.ObjectType{
AttrTypes: VersionsValue{}.AttributeTypes(ctx),
},
},
},
Computed: true,
Description: "A list containing available postgres versions.",
MarkdownDescription: "A list containing available postgres versions.",
},
},
}
}
type VersionsModel struct {
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
Versions types.List `tfsdk:"versions"`
}
var _ basetypes.ObjectTypable = VersionsType{}
type VersionsType struct {
basetypes.ObjectType
}
func (t VersionsType) Equal(o attr.Type) bool {
other, ok := o.(VersionsType)
if !ok {
return false
}
return t.ObjectType.Equal(other.ObjectType)
}
func (t VersionsType) String() string {
return "VersionsType"
}
func (t VersionsType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) {
var diags diag.Diagnostics
attributes := in.Attributes()
betaAttribute, ok := attributes["beta"]
if !ok {
diags.AddError(
"Attribute Missing",
`beta is missing from object`)
return nil, diags
}
betaVal, ok := betaAttribute.(basetypes.BoolValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`beta expected to be basetypes.BoolValue, was: %T`, betaAttribute))
}
deprecatedAttribute, ok := attributes["deprecated"]
if !ok {
diags.AddError(
"Attribute Missing",
`deprecated is missing from object`)
return nil, diags
}
deprecatedVal, ok := deprecatedAttribute.(basetypes.StringValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`deprecated expected to be basetypes.StringValue, was: %T`, deprecatedAttribute))
}
recommendAttribute, ok := attributes["recommend"]
if !ok {
diags.AddError(
"Attribute Missing",
`recommend is missing from object`)
return nil, diags
}
recommendVal, ok := recommendAttribute.(basetypes.BoolValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`recommend expected to be basetypes.BoolValue, was: %T`, recommendAttribute))
}
versionAttribute, ok := attributes["version"]
if !ok {
diags.AddError(
"Attribute Missing",
`version is missing from object`)
return nil, diags
}
versionVal, ok := versionAttribute.(basetypes.StringValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`version expected to be basetypes.StringValue, was: %T`, versionAttribute))
}
if diags.HasError() {
return nil, diags
}
return VersionsValue{
Beta: betaVal,
Deprecated: deprecatedVal,
Recommend: recommendVal,
Version: versionVal,
state: attr.ValueStateKnown,
}, diags
}
func NewVersionsValueNull() VersionsValue {
return VersionsValue{
state: attr.ValueStateNull,
}
}
func NewVersionsValueUnknown() VersionsValue {
return VersionsValue{
state: attr.ValueStateUnknown,
}
}
func NewVersionsValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (VersionsValue, diag.Diagnostics) {
var diags diag.Diagnostics
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521
ctx := context.Background()
for name, attributeType := range attributeTypes {
attribute, ok := attributes[name]
if !ok {
diags.AddError(
"Missing VersionsValue Attribute Value",
"While creating a VersionsValue value, a missing attribute value was detected. "+
"A VersionsValue must contain values for all attributes, even if null or unknown. "+
"This is always an issue with the provider and should be reported to the provider developers.\n\n"+
fmt.Sprintf("VersionsValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()),
)
continue
}
if !attributeType.Equal(attribute.Type(ctx)) {
diags.AddError(
"Invalid VersionsValue Attribute Type",
"While creating a VersionsValue value, an invalid attribute value was detected. "+
"A VersionsValue must use a matching attribute type for the value. "+
"This is always an issue with the provider and should be reported to the provider developers.\n\n"+
fmt.Sprintf("VersionsValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+
fmt.Sprintf("VersionsValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)),
)
}
}
for name := range attributes {
_, ok := attributeTypes[name]
if !ok {
diags.AddError(
"Extra VersionsValue Attribute Value",
"While creating a VersionsValue value, an extra attribute value was detected. "+
"A VersionsValue must not contain values beyond the expected attribute types. "+
"This is always an issue with the provider and should be reported to the provider developers.\n\n"+
fmt.Sprintf("Extra VersionsValue Attribute Name: %s", name),
)
}
}
if diags.HasError() {
return NewVersionsValueUnknown(), diags
}
betaAttribute, ok := attributes["beta"]
if !ok {
diags.AddError(
"Attribute Missing",
`beta is missing from object`)
return NewVersionsValueUnknown(), diags
}
betaVal, ok := betaAttribute.(basetypes.BoolValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`beta expected to be basetypes.BoolValue, was: %T`, betaAttribute))
}
deprecatedAttribute, ok := attributes["deprecated"]
if !ok {
diags.AddError(
"Attribute Missing",
`deprecated is missing from object`)
return NewVersionsValueUnknown(), diags
}
deprecatedVal, ok := deprecatedAttribute.(basetypes.StringValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`deprecated expected to be basetypes.StringValue, was: %T`, deprecatedAttribute))
}
recommendAttribute, ok := attributes["recommend"]
if !ok {
diags.AddError(
"Attribute Missing",
`recommend is missing from object`)
return NewVersionsValueUnknown(), diags
}
recommendVal, ok := recommendAttribute.(basetypes.BoolValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`recommend expected to be basetypes.BoolValue, was: %T`, recommendAttribute))
}
versionAttribute, ok := attributes["version"]
if !ok {
diags.AddError(
"Attribute Missing",
`version is missing from object`)
return NewVersionsValueUnknown(), diags
}
versionVal, ok := versionAttribute.(basetypes.StringValue)
if !ok {
diags.AddError(
"Attribute Wrong Type",
fmt.Sprintf(`version expected to be basetypes.StringValue, was: %T`, versionAttribute))
}
if diags.HasError() {
return NewVersionsValueUnknown(), diags
}
return VersionsValue{
Beta: betaVal,
Deprecated: deprecatedVal,
Recommend: recommendVal,
Version: versionVal,
state: attr.ValueStateKnown,
}, diags
}
func NewVersionsValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) VersionsValue {
object, diags := NewVersionsValue(attributeTypes, attributes)
if diags.HasError() {
// This could potentially be added to the diag package.
diagsStrings := make([]string, 0, len(diags))
for _, diagnostic := range diags {
diagsStrings = append(diagsStrings, fmt.Sprintf(
"%s | %s | %s",
diagnostic.Severity(),
diagnostic.Summary(),
diagnostic.Detail()))
}
panic("NewVersionsValueMust received error(s): " + strings.Join(diagsStrings, "\n"))
}
return object
}
func (t VersionsType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) {
if in.Type() == nil {
return NewVersionsValueNull(), nil
}
if !in.Type().Equal(t.TerraformType(ctx)) {
return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type())
}
if !in.IsKnown() {
return NewVersionsValueUnknown(), nil
}
if in.IsNull() {
return NewVersionsValueNull(), nil
}
attributes := map[string]attr.Value{}
val := map[string]tftypes.Value{}
err := in.As(&val)
if err != nil {
return nil, err
}
for k, v := range val {
a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v)
if err != nil {
return nil, err
}
attributes[k] = a
}
return NewVersionsValueMust(VersionsValue{}.AttributeTypes(ctx), attributes), nil
}
func (t VersionsType) ValueType(ctx context.Context) attr.Value {
return VersionsValue{}
}
var _ basetypes.ObjectValuable = VersionsValue{}
type VersionsValue struct {
Beta basetypes.BoolValue `tfsdk:"beta"`
Deprecated basetypes.StringValue `tfsdk:"deprecated"`
Recommend basetypes.BoolValue `tfsdk:"recommend"`
Version basetypes.StringValue `tfsdk:"version"`
state attr.ValueState
}
func (v VersionsValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) {
attrTypes := make(map[string]tftypes.Type, 4)
var val tftypes.Value
var err error
attrTypes["beta"] = basetypes.BoolType{}.TerraformType(ctx)
attrTypes["deprecated"] = basetypes.StringType{}.TerraformType(ctx)
attrTypes["recommend"] = basetypes.BoolType{}.TerraformType(ctx)
attrTypes["version"] = basetypes.StringType{}.TerraformType(ctx)
objectType := tftypes.Object{AttributeTypes: attrTypes}
switch v.state {
case attr.ValueStateKnown:
vals := make(map[string]tftypes.Value, 4)
val, err = v.Beta.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["beta"] = val
val, err = v.Deprecated.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["deprecated"] = val
val, err = v.Recommend.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["recommend"] = val
val, err = v.Version.ToTerraformValue(ctx)
if err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
vals["version"] = val
if err := tftypes.ValidateValue(objectType, vals); err != nil {
return tftypes.NewValue(objectType, tftypes.UnknownValue), err
}
return tftypes.NewValue(objectType, vals), nil
case attr.ValueStateNull:
return tftypes.NewValue(objectType, nil), nil
case attr.ValueStateUnknown:
return tftypes.NewValue(objectType, tftypes.UnknownValue), nil
default:
panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state))
}
}
func (v VersionsValue) IsNull() bool {
return v.state == attr.ValueStateNull
}
func (v VersionsValue) IsUnknown() bool {
return v.state == attr.ValueStateUnknown
}
func (v VersionsValue) String() string {
return "VersionsValue"
}
func (v VersionsValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) {
var diags diag.Diagnostics
attributeTypes := map[string]attr.Type{
"beta": basetypes.BoolType{},
"deprecated": basetypes.StringType{},
"recommend": basetypes.BoolType{},
"version": basetypes.StringType{},
}
if v.IsNull() {
return types.ObjectNull(attributeTypes), diags
}
if v.IsUnknown() {
return types.ObjectUnknown(attributeTypes), diags
}
objVal, diags := types.ObjectValue(
attributeTypes,
map[string]attr.Value{
"beta": v.Beta,
"deprecated": v.Deprecated,
"recommend": v.Recommend,
"version": v.Version,
})
return objVal, diags
}
func (v VersionsValue) Equal(o attr.Value) bool {
other, ok := o.(VersionsValue)
if !ok {
return false
}
if v.state != other.state {
return false
}
if v.state != attr.ValueStateKnown {
return true
}
if !v.Beta.Equal(other.Beta) {
return false
}
if !v.Deprecated.Equal(other.Deprecated) {
return false
}
if !v.Recommend.Equal(other.Recommend) {
return false
}
if !v.Version.Equal(other.Version) {
return false
}
return true
}
func (v VersionsValue) Type(ctx context.Context) attr.Type {
return VersionsType{
basetypes.ObjectType{
AttrTypes: v.AttributeTypes(ctx),
},
}
}
func (v VersionsValue) AttributeTypes(ctx context.Context) map[string]attr.Type {
return map[string]attr.Type{
"beta": basetypes.BoolType{},
"deprecated": basetypes.StringType{},
"recommend": basetypes.BoolType{},
"version": basetypes.StringType{},
}
}

View file

@ -0,0 +1,175 @@
package sqlserverflexalpha
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
sqlserverflexalphaPkg "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/v3alpha1api"
sqlserverflexalphaGen "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/sqlserverflexalpha/database/datasources_gen"
)
var _ datasource.DataSource = (*databaseDataSource)(nil)
const errorPrefix = "[sqlserverflexalpha - Database]"
func NewDatabaseDataSource() datasource.DataSource {
return &databaseDataSource{}
}
type dataSourceModel struct {
sqlserverflexalphaGen.DatabaseModel
TerraformId types.String `tfsdk:"id"`
}
type databaseDataSource struct {
client *sqlserverflexalphaPkg.APIClient
providerData core.ProviderData
}
func (d *databaseDataSource) Metadata(
_ context.Context,
req datasource.MetadataRequest,
resp *datasource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_sqlserverflexalpha_database"
}
func (d *databaseDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = sqlserverflexalphaGen.DatabaseDataSourceSchema(ctx)
resp.Schema.Attributes["id"] = schema.StringAttribute{
Computed: true,
Description: "The terraform internal identifier.",
MarkdownDescription: "The terraform internal identifier.",
}
}
// Configure adds the provider configured client to the data source.
func (d *databaseDataSource) Configure(
ctx context.Context,
req datasource.ConfigureRequest,
resp *datasource.ConfigureResponse,
) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(d.providerData.RoundTripper),
utils.UserAgentConfigOption(d.providerData.Version),
}
if d.providerData.SQLServerFlexCustomEndpoint != "" {
apiClientConfigOptions = append(
apiClientConfigOptions,
config.WithEndpoint(d.providerData.SQLServerFlexCustomEndpoint),
)
} else {
apiClientConfigOptions = append(
apiClientConfigOptions,
config.WithRegion(d.providerData.GetRegion()),
)
}
apiClient, err := sqlserverflexalphaPkg.NewAPIClient(apiClientConfigOptions...)
if err != nil {
resp.Diagnostics.AddError(
"Error configuring API client",
fmt.Sprintf(
"Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration",
err,
),
)
return
}
d.client = apiClient
tflog.Info(ctx, fmt.Sprintf("%s client configured", errorPrefix))
}
func (d *databaseDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var data dataSourceModel
// Read Terraform configuration data into the model
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
// Extract identifiers from the plan
projectId := data.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(data.Region)
instanceId := data.InstanceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
databaseName := data.DatabaseName.ValueString()
databaseResp, err := d.client.DefaultAPI.GetDatabaseRequest(ctx, projectId, region, instanceId, databaseName).Execute()
if err != nil {
handleReadError(ctx, &resp.Diagnostics, err, projectId, instanceId)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema and populate Computed attribute values
err = mapFields(databaseResp, &data, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
tflog.Info(ctx, "SQL Server Flex Alpha database read")
}
// handleReadError centralizes API error handling for the Read operation.
func handleReadError(ctx context.Context, diags *diag.Diagnostics, err error, projectId, instanceId string) {
utils.LogError(
ctx,
diags,
err,
"Reading database",
fmt.Sprintf(
"Could not retrieve database for instance %q in project %q.",
instanceId,
projectId,
),
map[int]string{
http.StatusBadRequest: fmt.Sprintf(
"Invalid request parameters for project %q and instance %q.",
projectId,
instanceId,
),
http.StatusNotFound: fmt.Sprintf(
"Database, instance %q, or project %q not found.",
instanceId,
projectId,
),
http.StatusForbidden: fmt.Sprintf("Forbidden access to project %q.", projectId),
},
)
}

View file

@ -0,0 +1,81 @@
// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
package sqlserverflexalpha
import (
"context"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
)
func DatabaseDataSourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"collation_name": schema.StringAttribute{
Computed: true,
Description: "The collation of the database. This database collation should match the *collation_name* of one of the collations given by the **Get database collation list** endpoint.",
MarkdownDescription: "The collation of the database. This database collation should match the *collation_name* of one of the collations given by the **Get database collation list** endpoint.",
},
"compatibility_level": schema.Int64Attribute{
Computed: true,
Description: "CompatibilityLevel of the Database.",
MarkdownDescription: "CompatibilityLevel of the Database.",
},
"database_name": schema.StringAttribute{
Required: true,
Description: "The name of the database.",
MarkdownDescription: "The name of the database.",
},
"tf_original_api_id": schema.Int64Attribute{
Computed: true,
Description: "The id of the database.",
MarkdownDescription: "The id of the database.",
},
"instance_id": schema.StringAttribute{
Required: true,
Description: "The ID of the instance.",
MarkdownDescription: "The ID of the instance.",
},
"name": schema.StringAttribute{
Computed: true,
Description: "The name of the database.",
MarkdownDescription: "The name of the database.",
},
"owner": schema.StringAttribute{
Computed: true,
Description: "The owner of the database.",
MarkdownDescription: "The owner of the database.",
},
"project_id": schema.StringAttribute{
Required: true,
Description: "The STACKIT project ID.",
MarkdownDescription: "The STACKIT project ID.",
},
"region": schema.StringAttribute{
Required: true,
Description: "The region which should be addressed",
MarkdownDescription: "The region which should be addressed",
Validators: []validator.String{
stringvalidator.OneOf(
"eu01",
),
},
},
},
}
}
type DatabaseModel struct {
CollationName types.String `tfsdk:"collation_name"`
CompatibilityLevel types.Int64 `tfsdk:"compatibility_level"`
DatabaseName types.String `tfsdk:"database_name"`
Id types.Int64 `tfsdk:"tf_original_api_id"`
InstanceId types.String `tfsdk:"instance_id"`
Name types.String `tfsdk:"name"`
Owner types.String `tfsdk:"owner"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
}

View file

@ -0,0 +1,106 @@
package sqlserverflexalpha
import (
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework/types"
coreUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
sqlserverflexalpha "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
)
// mapFields maps fields from a ListDatabase API response to a resourceModel for the data source.
func mapFields(source *sqlserverflexalpha.GetDatabaseResponse, model *dataSourceModel, region string) error {
if source == nil {
return fmt.Errorf("response is nil")
}
if source.Id == 0 {
return fmt.Errorf("id not present")
}
if model == nil {
return fmt.Errorf("model given is nil")
}
var databaseId int64
if model.Id.ValueInt64() != 0 {
databaseId = model.Id.ValueInt64()
} else if source.Id != 0 {
databaseId = source.Id
} else {
return fmt.Errorf("database id not present")
}
model.Id = types.Int64Value(databaseId)
model.DatabaseName = types.StringValue(source.GetName())
model.Name = types.StringValue(source.GetName())
model.Owner = types.StringValue(strings.Trim(source.GetOwner(), "\""))
model.Region = types.StringValue(region)
model.ProjectId = types.StringValue(model.ProjectId.ValueString())
model.InstanceId = types.StringValue(model.InstanceId.ValueString())
model.CompatibilityLevel = types.Int64Value(int64(source.GetCompatibilityLevel()))
model.CollationName = types.StringValue(source.GetCollationName())
model.TerraformId = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(),
region,
model.InstanceId.ValueString(),
model.DatabaseName.ValueString(),
)
return nil
}
// mapResourceFields maps fields from a ListDatabase API response to a resourceModel for the resource.
func mapResourceFields(source *sqlserverflexalpha.GetDatabaseResponse, model *resourceModel, region string) error {
if source == nil {
return fmt.Errorf("response is nil")
}
if source.Id == 0 {
return fmt.Errorf("id not present")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var databaseId int64
if model.Id.ValueInt64() != 0 {
databaseId = model.Id.ValueInt64()
} else if source.Id != 0 {
databaseId = source.Id
} else {
return fmt.Errorf("database id not present")
}
model.Id = types.Int64Value(databaseId)
model.DatabaseName = types.StringValue(source.GetName())
model.Name = types.StringValue(source.GetName())
model.Owner = types.StringValue(strings.Trim(source.GetOwner(), "\""))
model.Region = types.StringValue(region)
model.ProjectId = types.StringValue(model.ProjectId.ValueString())
model.InstanceId = types.StringValue(model.InstanceId.ValueString())
model.Compatibility = types.Int64Value(int64(source.GetCompatibilityLevel()))
model.CompatibilityLevel = types.Int64Value(int64(source.GetCompatibilityLevel()))
model.Collation = types.StringValue(source.GetCollationName()) // it does not come back from api
model.CollationName = types.StringValue(source.GetCollationName())
return nil
}
// toCreatePayload converts the resource model to an API create payload.
func toCreatePayload(model *resourceModel) (*sqlserverflexalpha.CreateDatabaseRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &sqlserverflexalpha.CreateDatabaseRequestPayload{
Name: model.Name.ValueString(),
Owner: model.Owner.ValueString(),
Collation: model.Collation.ValueStringPointer(),
Compatibility: coreUtils.Ptr(int32(model.Compatibility.ValueInt64())), //nolint:gosec // TODO
}, nil
}

View file

@ -0,0 +1,233 @@
package sqlserverflexalpha
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
sqlserverflexalpha "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/v3alpha1api"
datasource "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/sqlserverflexalpha/database/datasources_gen"
)
func TestMapFields(t *testing.T) {
type given struct {
source *sqlserverflexalpha.GetDatabaseResponse
model *dataSourceModel
region string
}
type expected struct {
model *dataSourceModel
err bool
}
testcases := []struct {
name string
given given
expected expected
}{
{
name: "should map fields correctly",
given: given{
source: &sqlserverflexalpha.GetDatabaseResponse{
Id: (int64(1)),
Name: ("my-db"),
CollationName: ("collation"),
CompatibilityLevel: (int32(150)),
Owner: ("my-owner"),
},
model: &dataSourceModel{
DatabaseModel: datasource.DatabaseModel{
ProjectId: types.StringValue("my-project"),
InstanceId: types.StringValue("my-instance"),
},
},
region: "eu01",
},
expected: expected{
model: &dataSourceModel{
DatabaseModel: datasource.DatabaseModel{
Id: types.Int64Value(1),
Name: types.StringValue("my-db"),
DatabaseName: types.StringValue("my-db"),
Owner: types.StringValue("my-owner"),
Region: types.StringValue("eu01"),
InstanceId: types.StringValue("my-instance"),
ProjectId: types.StringValue("my-project"),
CompatibilityLevel: types.Int64Value(150),
CollationName: types.StringValue("collation"),
},
TerraformId: types.StringValue("my-project,eu01,my-instance,my-db"),
},
},
},
{
name: "should fail on nil source",
given: given{
source: nil,
model: &dataSourceModel{},
},
expected: expected{err: true},
},
{
name: "should fail on nil source ID",
given: given{
source: &sqlserverflexalpha.GetDatabaseResponse{Id: 0},
model: &dataSourceModel{},
},
expected: expected{err: true},
},
{
name: "should fail on nil model",
given: given{
source: &sqlserverflexalpha.GetDatabaseResponse{Id: (int64(1))},
model: nil,
},
expected: expected{err: true},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
err := mapFields(tc.given.source, tc.given.model, tc.given.region)
if (err != nil) != tc.expected.err {
t.Fatalf("expected error: %v, got: %v", tc.expected.err, err)
}
if err == nil {
if diff := cmp.Diff(tc.expected.model, tc.given.model); diff != "" {
t.Errorf("model mismatch (-want +got):\n%s", diff)
}
}
},
)
}
}
func TestMapResourceFields(t *testing.T) {
type given struct {
source *sqlserverflexalpha.GetDatabaseResponse
model *resourceModel
region string
}
type expected struct {
model *resourceModel
err bool
}
testcases := []struct {
name string
given given
expected expected
}{
{
name: "should map fields correctly",
given: given{
source: &sqlserverflexalpha.GetDatabaseResponse{
Id: (int64(1)),
Name: ("my-db"),
Owner: ("my-owner"),
},
model: &resourceModel{
ProjectId: types.StringValue("my-project"),
InstanceId: types.StringValue("my-instance"),
},
region: "eu01",
},
expected: expected{
model: &resourceModel{
Id: types.Int64Value(1),
Name: types.StringValue("my-db"),
Compatibility: types.Int64Value(0),
CompatibilityLevel: types.Int64Value(0),
Collation: types.StringValue(""),
CollationName: types.StringValue(""),
DatabaseName: types.StringValue("my-db"),
InstanceId: types.StringValue("my-instance"),
ProjectId: types.StringValue("my-project"),
Region: types.StringValue("eu01"),
Owner: types.StringValue("my-owner"),
},
},
},
{
name: "should fail on nil source",
given: given{
source: nil,
model: &resourceModel{},
},
expected: expected{err: true},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
err := mapResourceFields(tc.given.source, tc.given.model, tc.given.region)
if (err != nil) != tc.expected.err {
t.Fatalf("expected error: %v, got: %v", tc.expected.err, err)
}
if err == nil {
if diff := cmp.Diff(tc.expected.model, tc.given.model); diff != "" {
t.Errorf("model mismatch (-want +got):\n%s", diff)
}
}
},
)
}
}
func TestToCreatePayload(t *testing.T) {
type given struct {
model *resourceModel
}
type expected struct {
payload *sqlserverflexalpha.CreateDatabaseRequestPayload
err bool
}
testcases := []struct {
name string
given given
expected expected
}{
{
name: "should convert model to payload",
given: given{
model: &resourceModel{
Name: types.StringValue("my-db"),
Owner: types.StringValue("my-owner"),
},
},
expected: expected{
payload: &sqlserverflexalpha.CreateDatabaseRequestPayload{
Name: "my-db",
Owner: "my-owner",
Compatibility: utils.Ptr(int32(0)),
},
},
},
{
name: "should fail on nil model",
given: given{model: nil},
expected: expected{err: true},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
actual, err := toCreatePayload(tc.given.model)
if (err != nil) != tc.expected.err {
t.Fatalf("expected error: %v, got: %v", tc.expected.err, err)
}
if err == nil {
if diff := cmp.Diff(tc.expected.payload, actual); diff != "" {
t.Errorf("payload mismatch (-want +got):\n%s", diff)
}
}
},
)
}
}

View file

@ -0,0 +1,51 @@
fields:
- name: 'id'
modifiers:
- 'UseStateForUnknown'
- name: 'instance_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'RequiresReplace'
- name: 'project_id'
validators:
- validate.NoSeparator
- validate.UUID
modifiers:
- 'RequiresReplace'
- name: 'region'
modifiers:
- 'RequiresReplace'
- name: 'name'
modifiers:
- 'RequiresReplace'
- name: 'collation'
modifiers:
- 'RequiresReplace'
- name: 'owner'
modifiers:
- 'UseStateForUnknown'
- name: 'database_name'
modifiers:
- 'UseStateForUnknown'
- name: 'collation_name'
modifiers:
- 'UseStateForUnknown'
- name: 'compatibility'
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'compatibility_level'
modifiers:
- 'UseStateForUnknown'

View file

@ -0,0 +1,541 @@
package sqlserverflexalpha
import (
"context"
_ "embed"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/identityschema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
coreUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
sqlserverflexalpha "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/v3alpha1api"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
wait "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/wait/sqlserverflexalpha"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
sqlserverflexalphaResGen "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/sqlserverflexalpha/database/resources_gen"
)
var (
_ resource.Resource = &databaseResource{}
_ resource.ResourceWithConfigure = &databaseResource{}
_ resource.ResourceWithImportState = &databaseResource{}
_ resource.ResourceWithModifyPlan = &databaseResource{}
_ resource.ResourceWithIdentity = &databaseResource{}
// Define errors
errDatabaseNotFound = errors.New("database not found")
)
func NewDatabaseResource() resource.Resource {
return &databaseResource{}
}
// resourceModel describes the resource data model.
type resourceModel = sqlserverflexalphaResGen.DatabaseModel
type databaseResource struct {
client *sqlserverflexalpha.APIClient
providerData core.ProviderData
}
type DatabaseResourceIdentityModel struct {
ProjectID types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
InstanceID types.String `tfsdk:"instance_id"`
DatabaseName types.String `tfsdk:"database_name"`
}
func (r *databaseResource) Metadata(
_ context.Context,
req resource.MetadataRequest,
resp *resource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_sqlserverflexalpha_database"
}
//go:embed planModifiers.yaml
var modifiersFileByte []byte
func (r *databaseResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
s := sqlserverflexalphaResGen.DatabaseResourceSchema(ctx)
fields, err := utils.ReadModifiersConfig(modifiersFileByte)
if err != nil {
resp.Diagnostics.AddError("error during read modifiers config file", err.Error())
return
}
err = utils.AddPlanModifiersToResourceSchema(fields, &s)
if err != nil {
resp.Diagnostics.AddError("error adding plan modifiers", err.Error())
return
}
resp.Schema = s
}
func (r *databaseResource) IdentitySchema(
_ context.Context,
_ resource.IdentitySchemaRequest,
resp *resource.IdentitySchemaResponse,
) {
resp.IdentitySchema = identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"project_id": identityschema.StringAttribute{
RequiredForImport: true, // must be set during import by the practitioner
},
"region": identityschema.StringAttribute{
RequiredForImport: true, // can be defaulted by the provider configuration
},
"instance_id": identityschema.StringAttribute{
RequiredForImport: true, // can be defaulted by the provider configuration
},
"database_name": identityschema.StringAttribute{
RequiredForImport: true, // can be defaulted by the provider configuration
},
},
}
}
// Configure adds the provider configured client to the resource.
func (r *databaseResource) Configure(
ctx context.Context,
req resource.ConfigureRequest,
resp *resource.ConfigureResponse,
) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(r.providerData.RoundTripper),
utils.UserAgentConfigOption(r.providerData.Version),
}
if r.providerData.SQLServerFlexCustomEndpoint != "" {
apiClientConfigOptions = append(
apiClientConfigOptions,
config.WithEndpoint(r.providerData.SQLServerFlexCustomEndpoint),
)
} else {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithRegion(r.providerData.GetRegion()))
}
apiClient, err := sqlserverflexalpha.NewAPIClient(apiClientConfigOptions...)
if err != nil {
resp.Diagnostics.AddError(
"Error configuring API client",
fmt.Sprintf(
"Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration",
err,
),
)
return
}
r.client = apiClient
tflog.Info(ctx, "sqlserverflexalpha.Database client configured")
}
func (r *databaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data resourceModel
createErr := "DB create error"
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := data.ProjectId.ValueString()
region := data.Region.ValueString()
instanceId := data.InstanceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
databaseName := data.Name.ValueString()
ctx = tflog.SetField(ctx, "database_name", databaseName)
payLoad := sqlserverflexalpha.CreateDatabaseRequestPayload{}
if !data.Collation.IsNull() && !data.Collation.IsUnknown() {
payLoad.Collation = data.Collation.ValueStringPointer()
}
if !data.Compatibility.IsNull() && !data.Compatibility.IsUnknown() {
payLoad.Compatibility = coreUtils.Ptr(int32(data.Compatibility.ValueInt64())) //nolint:gosec // TODO
}
payLoad.Name = data.Name.ValueString()
payLoad.Owner = data.Owner.ValueString()
createResp, err := r.client.DefaultAPI.CreateDatabaseRequest(ctx, projectId, region, instanceId).
CreateDatabaseRequestPayload(payLoad).
Execute()
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
createErr,
fmt.Sprintf("Calling API: %v", err),
)
return
}
if createResp == nil || createResp.Id == 0 {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
"API didn't return database Id. A database might have been created",
)
return
}
databaseId := createResp.Id
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = core.LogResponse(ctx)
// Set data returned by API in identity
identity := DatabaseResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
DatabaseName: types.StringValue(databaseName),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
// TODO: is this necessary to wait for the database-> API say 200 ?
waitResp, err := wait.CreateDatabaseWaitHandler(
ctx,
r.client.DefaultAPI,
projectId,
instanceId,
region,
databaseName,
).SetSleepBeforeWait(
30 * time.Second,
).SetTimeout(
15 * time.Minute,
).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
createErr,
fmt.Sprintf("Database creation waiting: %v", err),
)
return
}
if waitResp.Id == 0 {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
createErr,
"Database creation waiting: returned id is nil",
)
return
}
if waitResp.Id != databaseId {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
createErr,
"Database creation waiting: returned id is different",
)
return
}
if waitResp.Owner != data.Owner.ValueString() {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
createErr,
"Database creation waiting: returned owner is different",
)
return
}
if waitResp.Name != data.Name.ValueString() {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
createErr,
"Database creation waiting: returned name is different",
)
return
}
database, err := r.client.DefaultAPI.GetDatabaseRequest(ctx, projectId, region, instanceId, databaseName).Execute()
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
fmt.Sprintf("Getting database details after creation: %v", err),
)
return
}
// Map response body to schema
err = mapResourceFields(database, &data, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Set state to fully populated data
resp.Diagnostics.Append(resp.State.Set(ctx, data)...)
if resp.Diagnostics.HasError() {
return
}
// Save data into Terraform state
tflog.Info(ctx, "sqlserverflexalpha.Database created")
}
func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var model resourceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
instanceId := model.InstanceId.ValueString()
databaseName := model.DatabaseName.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "database_name", databaseName)
databaseResp, err := r.client.DefaultAPI.GetDatabaseRequest(ctx, projectId, region, instanceId, databaseName).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) || errors.Is(err, errDatabaseNotFound) {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapResourceFields(databaseResp, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Save identity into Terraform state
identity := DatabaseResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
DatabaseName: types.StringValue(databaseName),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
// Set refreshed state
resp.Diagnostics.Append(resp.State.Set(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "sqlserverflexalpha.Database read")
}
func (r *databaseResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
// TODO: Check update api endpoint - not available at the moment, so return an error for now
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", "Database can't be updated")
}
func (r *databaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// nolint:gocritic // function signature required by Terraform
var model resourceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Read identity data
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
instanceId := model.InstanceId.ValueString()
databaseName := model.DatabaseName.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "database_name", databaseName)
// Delete existing record set
err := r.client.DefaultAPI.DeleteDatabaseRequest(ctx, projectId, region, instanceId, databaseName).Execute()
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error deleting database",
fmt.Sprintf(
"Calling API: %v\nname: %s, region: %s, instanceId: %s", err, databaseName, region, instanceId,
),
)
return
}
ctx = core.LogResponse(ctx)
resp.State.RemoveResource(ctx)
tflog.Info(ctx, "sqlserverflexalpha.Database deleted")
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *databaseResource) ModifyPlan(
ctx context.Context,
req resource.ModifyPlanRequest,
resp *resource.ModifyPlanResponse,
) { // nolint:gocritic // function signature required by Terraform
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
var configModel resourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel resourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}