Alpha (#4)
Some checks failed
CI Workflow / CI (push) Has been cancelled
CI Workflow / Check GoReleaser config (push) Has been cancelled
CI Workflow / Code coverage report (push) Has been cancelled

* chore: initial push to be able to work together

* chore: add missing wait folder

* chore: add missing folders

* chore: cleanup alpha branch

* feat: mssql alpha instance (#2)

* fix: remove unused attribute types and functions from backup models

* fix: update API client references to use sqlserverflexalpha package

* fix: update package references to use sqlserverflexalpha and modify user data source model

* fix: add sqlserverflexalpha user data source to provider

* fix: add sqlserverflexalpha user resource and update related functionality

* chore: add stackit_sqlserverflexalpha_user resource and instance_id variable

* fix: refactor sqlserverflexalpha user resource and enhance schema with status and default_database

---------

Co-authored-by: Andre Harms <andre.harms@stackit.cloud>
Co-authored-by: Marcel S. Henselin <marcel.henselin@stackit.cloud>

* feat: add sqlserver instance

* chore: fixing tests

* chore: update docs

---------

Co-authored-by: Marcel S. Henselin <marcel.henselin@stackit.cloud>
Co-authored-by: Andre Harms <andre.harms@stackit.cloud>
This commit is contained in:
Marcel S. Henselin 2025-12-19 11:37:53 +01:00 committed by GitHub
parent 45073a716b
commit 2733834fc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
351 changed files with 62744 additions and 3 deletions

View file

@ -0,0 +1,201 @@
// Copyright (c) STACKIT
package conversion
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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"
)
func ToString(ctx context.Context, v attr.Value) (string, error) {
if t := v.Type(ctx); t != types.StringType {
return "", fmt.Errorf("type mismatch. expected 'types.StringType' but got '%s'", t.String())
}
if v.IsNull() || v.IsUnknown() {
return "", fmt.Errorf("value is unknown or null")
}
tv, err := v.ToTerraformValue(ctx)
if err != nil {
return "", err
}
var s string
if err := tv.Copy().As(&s); err != nil {
return "", err
}
return s, nil
}
func ToOptStringMap(tfMap map[string]attr.Value) (*map[string]string, error) { //nolint: gocritic //pointer needed to map optional fields
labels := make(map[string]string, len(tfMap))
for l, v := range tfMap {
valueString, ok := v.(types.String)
if !ok {
return nil, fmt.Errorf("error converting map value: expected to string, got %v", v)
}
labels[l] = valueString.ValueString()
}
labelsPointer := &labels
if len(labels) == 0 {
labelsPointer = nil
}
return labelsPointer, nil
}
func ToTerraformStringMap(ctx context.Context, m map[string]string) (basetypes.MapValue, error) {
labels := make(map[string]attr.Value, len(m))
for l, v := range m {
stringValue := types.StringValue(v)
labels[l] = stringValue
}
res, diags := types.MapValueFrom(ctx, types.StringType, m)
if diags.HasError() {
return types.MapNull(types.StringType), fmt.Errorf("converting to MapValue: %v", diags.Errors())
}
return res, nil
}
// ToStringInterfaceMap converts a basetypes.MapValue of Strings to a map[string]interface{}.
func ToStringInterfaceMap(ctx context.Context, m basetypes.MapValue) (map[string]interface{}, error) {
labels := map[string]string{}
diags := m.ElementsAs(ctx, &labels, false)
if diags.HasError() {
return nil, fmt.Errorf("converting from MapValue: %w", core.DiagsToError(diags))
}
interfaceMap := make(map[string]interface{}, len(labels))
for k, v := range labels {
interfaceMap[k] = v
}
return interfaceMap, nil
}
// StringValueToPointer converts basetypes.StringValue to a pointer to string.
// It returns nil if the value is null or unknown.
func StringValueToPointer(s basetypes.StringValue) *string {
if s.IsNull() || s.IsUnknown() {
return nil
}
value := s.ValueString()
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 {
if s.IsNull() || s.IsUnknown() {
return nil
}
value := s.ValueInt64()
return &value
}
// Float64ValueToPointer converts basetypes.Float64Value to a pointer to float64.
// It returns nil if the value is null or unknown.
func Float64ValueToPointer(s basetypes.Float64Value) *float64 {
if s.IsNull() || s.IsUnknown() {
return nil
}
value := s.ValueFloat64()
return &value
}
// BoolValueToPointer converts basetypes.BoolValue to a pointer to bool.
// It returns nil if the value is null or unknown.
func BoolValueToPointer(s basetypes.BoolValue) *bool {
if s.IsNull() || s.IsUnknown() {
return nil
}
value := s.ValueBool()
return &value
}
// StringListToPointer converts basetypes.ListValue to a pointer to a list of strings.
// It returns nil if the value is null or unknown.
func StringListToPointer(list basetypes.ListValue) (*[]string, error) {
if list.IsNull() || list.IsUnknown() {
return nil, nil
}
listStr := []string{}
for i, el := range list.Elements() {
elStr, ok := el.(types.String)
if !ok {
return nil, fmt.Errorf("element %d is not a string", i)
}
listStr = append(listStr, elStr.ValueString())
}
return &listStr, nil
}
// ToJSONMApPartialUpdatePayload returns a map[string]interface{} to be used in a PATCH request payload.
// It takes a current map as it is in the terraform state and a desired map as it is in the user configuratiom
// and builds a map which sets to null keys that should be removed, updates the values of existing keys and adds new keys
// This method is needed because in partial updates, e.g. if the key is not provided it is ignored and not removed
func ToJSONMapPartialUpdatePayload(ctx context.Context, current, desired types.Map) (map[string]interface{}, error) {
currentMap, err := ToStringInterfaceMap(ctx, current)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
desiredMap, err := ToStringInterfaceMap(ctx, desired)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
mapPayload := map[string]interface{}{}
// Update and remove existing keys
for k := range currentMap {
if desiredValue, ok := desiredMap[k]; ok {
mapPayload[k] = desiredValue
} else {
mapPayload[k] = nil
}
}
// Add new keys
for k, desiredValue := range desiredMap {
if _, ok := mapPayload[k]; !ok {
mapPayload[k] = desiredValue
}
}
return mapPayload, nil
}
func ParseProviderData(ctx context.Context, providerData any, diags *diag.Diagnostics) (core.ProviderData, bool) {
// Prevent panic if the provider has not been configured.
if providerData == nil {
return core.ProviderData{}, false
}
stackitProviderData, ok := providerData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Expected configure type core.ProviderData, got %T", providerData))
return core.ProviderData{}, false
}
return stackitProviderData, true
}
func ParseEphemeralProviderData(ctx context.Context, providerData any, diags *diag.Diagnostics) (core.EphemeralProviderData, bool) {
// Prevent panic if the provider has not been configured.
if providerData == nil {
return core.EphemeralProviderData{}, false
}
stackitProviderData, ok := providerData.(core.EphemeralProviderData)
if !ok {
core.LogAndAddError(ctx, diags, "Error configuring API client", "Expected configure type core.EphemeralProviderData")
return core.EphemeralProviderData{}, false
}
return stackitProviderData, true
}

View file

@ -0,0 +1,396 @@
// Copyright (c) STACKIT
package conversion
import (
"context"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)
func TestFromTerraformStringMapToInterfaceMap(t *testing.T) {
type args struct {
ctx context.Context
m basetypes.MapValue
}
tests := []struct {
name string
args args
want map[string]interface{}
wantErr bool
}{
{
name: "base",
args: args{
ctx: context.Background(),
m: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
"key3": types.StringValue("value3"),
}),
},
want: map[string]interface{}{
"key": "value",
"key2": "value2",
"key3": "value3",
},
wantErr: false,
},
{
name: "empty",
args: args{
ctx: context.Background(),
m: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
want: map[string]interface{}{},
wantErr: false,
},
{
name: "nil",
args: args{
ctx: context.Background(),
m: types.MapNull(types.StringType),
},
want: map[string]interface{}{},
wantErr: false,
},
{
name: "invalid type map (non-string)",
args: args{
ctx: context.Background(),
m: types.MapValueMust(types.Int64Type, map[string]attr.Value{
"key": types.Int64Value(1),
}),
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ToStringInterfaceMap(tt.args.ctx, tt.args.m)
if (err != nil) != tt.wantErr {
t.Errorf("FromTerraformStringMapToInterfaceMap() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("FromTerraformStringMapToInterfaceMap() = %v, want %v", got, tt.want)
}
})
}
}
func TestToJSONMapUpdatePayload(t *testing.T) {
tests := []struct {
description string
currentLabels types.Map
desiredLabels types.Map
expected map[string]interface{}
isValid bool
}{
{
"nothing_to_update",
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
map[string]interface{}{
"key": "value",
},
true,
},
{
"update_key_value",
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("updated_value"),
}),
map[string]interface{}{
"key": "updated_value",
},
true,
},
{
"remove_key",
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
}),
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
map[string]interface{}{
"key": "value",
"key2": nil,
},
true,
},
{
"add_new_key",
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
}),
map[string]interface{}{
"key": "value",
"key2": "value2",
},
true,
},
{
"empty_desired_map",
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
}),
types.MapValueMust(types.StringType, map[string]attr.Value{}),
map[string]interface{}{
"key": nil,
"key2": nil,
},
true,
},
{
"nil_desired_map",
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
}),
types.MapNull(types.StringType),
map[string]interface{}{
"key": nil,
"key2": nil,
},
true,
},
{
"empty_current_map",
types.MapValueMust(types.StringType, map[string]attr.Value{}),
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
}),
map[string]interface{}{
"key": "value",
"key2": "value2",
},
true,
},
{
"nil_current_map",
types.MapNull(types.StringType),
types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
"key2": types.StringValue("value2"),
}),
map[string]interface{}{
"key": "value",
"key2": "value2",
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := ToJSONMapPartialUpdatePayload(context.Background(), tt.currentLabels, tt.desiredLabels)
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 TestParseProviderData(t *testing.T) {
type args struct {
providerData any
}
type want struct {
ok bool
providerData core.ProviderData
}
tests := []struct {
name string
args args
want want
wantErr bool
}{
{
name: "provider has not been configured",
args: args{
providerData: nil,
},
want: want{
ok: false,
},
wantErr: false,
},
{
name: "invalid provider data",
args: args{
providerData: struct{}{},
},
want: want{
ok: false,
},
wantErr: true,
},
{
name: "valid provider data 1",
args: args{
providerData: core.ProviderData{},
},
want: want{
ok: true,
providerData: core.ProviderData{},
},
wantErr: false,
},
{
name: "valid provider data 2",
args: args{
providerData: core.ProviderData{
DefaultRegion: "eu02",
RabbitMQCustomEndpoint: "https://rabbitmq-custom-endpoint.api.stackit.cloud",
Version: "1.2.3",
},
},
want: want{
ok: true,
providerData: core.ProviderData{
DefaultRegion: "eu02",
RabbitMQCustomEndpoint: "https://rabbitmq-custom-endpoint.api.stackit.cloud",
Version: "1.2.3",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
diags := diag.Diagnostics{}
actual, ok := ParseProviderData(ctx, tt.args.providerData, &diags)
if diags.HasError() != tt.wantErr {
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
}
if ok != tt.want.ok {
t.Errorf("ParseProviderData() got = %v, want %v", ok, tt.want.ok)
}
if !reflect.DeepEqual(actual, tt.want.providerData) {
t.Errorf("ParseProviderData() got = %v, want %v", actual, tt.want)
}
})
}
}
func TestParseEphemeralProviderData(t *testing.T) {
type args struct {
providerData any
}
type want struct {
ok bool
providerData core.EphemeralProviderData
}
tests := []struct {
name string
args args
want want
wantErr bool
}{
{
name: "provider has not been configured",
args: args{
providerData: nil,
},
want: want{
ok: false,
},
wantErr: false,
},
{
name: "invalid provider data",
args: args{
providerData: struct{}{},
},
want: want{
ok: false,
},
wantErr: true,
},
{
name: "valid provider data 1",
args: args{
providerData: core.EphemeralProviderData{},
},
want: want{
ok: true,
providerData: core.EphemeralProviderData{},
},
wantErr: false,
},
{
name: "valid provider data 2",
args: args{
providerData: core.EphemeralProviderData{
PrivateKey: "",
PrivateKeyPath: "/home/dev/foo/private-key.json",
ServiceAccountKey: "",
ServiceAccountKeyPath: "/home/dev/foo/key.json",
TokenCustomEndpoint: "",
},
},
want: want{
ok: true,
providerData: core.EphemeralProviderData{
PrivateKey: "",
PrivateKeyPath: "/home/dev/foo/private-key.json",
ServiceAccountKey: "",
ServiceAccountKeyPath: "/home/dev/foo/key.json",
TokenCustomEndpoint: "",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
diags := diag.Diagnostics{}
actual, ok := ParseEphemeralProviderData(ctx, tt.args.providerData, &diags)
if diags.HasError() != tt.wantErr {
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
}
if ok != tt.want.ok {
t.Errorf("ParseProviderData() got = %v, want %v", ok, tt.want.ok)
}
if !reflect.DeepEqual(actual, tt.want.providerData) {
t.Errorf("ParseProviderData() got = %v, want %v", actual, tt.want)
}
})
}
}

View file

@ -0,0 +1,168 @@
// Copyright (c) STACKIT
package core
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/runtime"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
type ResourceType string
const (
Resource ResourceType = "resource"
Datasource ResourceType = "datasource"
EphemeralResource ResourceType = "ephemeral-resource"
// Separator used for concatenation of TF-internal resource ID
Separator = ","
ResourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level."
DatasourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level."
)
type EphemeralProviderData struct {
ProviderData
PrivateKey string
PrivateKeyPath string
ServiceAccountKey string
ServiceAccountKeyPath string
TokenCustomEndpoint string
}
type ProviderData struct {
RoundTripper http.RoundTripper
ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025.
// Deprecated: Use DefaultRegion instead
Region string
DefaultRegion string
AuthorizationCustomEndpoint string
CdnCustomEndpoint string
DnsCustomEndpoint string
GitCustomEndpoint string
IaaSCustomEndpoint string
KMSCustomEndpoint string
LoadBalancerCustomEndpoint string
LogMeCustomEndpoint string
MariaDBCustomEndpoint string
MongoDBFlexCustomEndpoint string
ModelServingCustomEndpoint string
ObjectStorageCustomEndpoint string
ObservabilityCustomEndpoint string
OpenSearchCustomEndpoint string
PostgresFlexCustomEndpoint string
RabbitMQCustomEndpoint string
RedisCustomEndpoint string
ResourceManagerCustomEndpoint string
ScfCustomEndpoint string
SecretsManagerCustomEndpoint string
SQLServerFlexCustomEndpoint string
ServerBackupCustomEndpoint string
ServerUpdateCustomEndpoint string
SKECustomEndpoint string
ServiceEnablementCustomEndpoint string
ServiceAccountCustomEndpoint string
EnableBetaResources bool
Experiments []string
Version string // version of the STACKIT Terraform provider
}
// GetRegion returns the effective region for the provider, falling back to the deprecated _region_ attribute
func (pd *ProviderData) GetRegion() string {
if pd.DefaultRegion != "" {
return pd.DefaultRegion
} else if pd.Region != "" {
return pd.Region
}
// final fallback
return "eu01"
}
func (pd *ProviderData) GetRegionWithOverride(overrideRegion types.String) string {
if overrideRegion.IsUnknown() || overrideRegion.IsNull() {
return pd.GetRegion()
}
return overrideRegion.ValueString()
}
// DiagsToError Converts TF diagnostics' errors into an error with a human-readable description.
// If there are no errors, the output is nil
func DiagsToError(diags diag.Diagnostics) error {
if !diags.HasError() {
return nil
}
diagsError := diags.Errors()
diagsStrings := make([]string, 0)
for _, diagnostic := range diagsError {
diagsStrings = append(diagsStrings, fmt.Sprintf(
"(%s) %s",
diagnostic.Summary(),
diagnostic.Detail(),
))
}
return fmt.Errorf("%s", strings.Join(diagsStrings, ";"))
}
// LogAndAddError Logs the error and adds it to the diags
func LogAndAddError(ctx context.Context, diags *diag.Diagnostics, summary, detail string) {
if traceId := runtime.GetTraceId(ctx); traceId != "" {
detail = fmt.Sprintf("%s\nTrace ID: %q", detail, traceId)
}
tflog.Error(ctx, fmt.Sprintf("%s | %s", summary, detail))
diags.AddError(summary, detail)
}
// LogAndAddWarning Logs the warning and adds it to the diags
func LogAndAddWarning(ctx context.Context, diags *diag.Diagnostics, summary, detail string) {
if traceId := runtime.GetTraceId(ctx); traceId != "" {
detail = fmt.Sprintf("%s\nTrace ID: %q", detail, traceId)
}
tflog.Warn(ctx, fmt.Sprintf("%s | %s", summary, detail))
diags.AddWarning(summary, detail)
}
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)
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)
tflog.Error(ctx, fmt.Sprintf("%s | %s", errTitle, errContent))
diags.AddError(errTitle, errContent)
}
// InitProviderContext extends the context to capture the http response
func InitProviderContext(ctx context.Context) context.Context {
// Capture http response to get trace-id
var httpResp *http.Response
return runtime.WithCaptureHTTPResponse(ctx, &httpResp)
}
// LogResponse logs the trace-id of the last request
func LogResponse(ctx context.Context) context.Context {
// Logs the trace-id of the request
traceId := runtime.GetTraceId(ctx)
ctx = tflog.SetField(ctx, "x-trace-id", traceId)
tflog.Info(ctx, "response data", map[string]interface{}{
"x-trace-id": traceId,
})
return ctx
}

View file

@ -0,0 +1,102 @@
// Copyright (c) STACKIT
package core
import (
"testing"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestProviderData_GetRegionWithOverride(t *testing.T) {
type args struct {
overrideRegion types.String
}
tests := []struct {
name string
providerData *ProviderData
args args
want string
}{
{
name: "override region is null string",
providerData: &ProviderData{
DefaultRegion: "eu02",
},
args: args{
types.StringNull(),
},
want: "eu02",
},
{
name: "override region is unknown string",
providerData: &ProviderData{
DefaultRegion: "eu02",
},
args: args{
types.StringUnknown(),
},
want: "eu02",
},
{
name: "override region is set",
providerData: &ProviderData{
DefaultRegion: "eu02",
},
args: args{
types.StringValue("eu01"),
},
want: "eu01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.providerData.GetRegionWithOverride(tt.args.overrideRegion); got != tt.want {
t.Errorf("GetRegionWithOverride() = %v, want %v", got, tt.want)
}
})
}
}
func TestProviderData_GetRegion(t *testing.T) {
tests := []struct {
name string
providerData *ProviderData
want string
}{
{
name: "default region is set",
providerData: &ProviderData{
DefaultRegion: "eu02",
},
want: "eu02",
},
{
name: "(legacy) region is set",
providerData: &ProviderData{
Region: "eu02",
},
want: "eu02",
},
{
name: "default region wins over (legacy) region",
providerData: &ProviderData{
DefaultRegion: "eu02",
Region: "eu01",
},
want: "eu02",
},
{
name: "final fallback - neither region (legacy) nor default region is set",
providerData: &ProviderData{},
want: "eu01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.providerData.GetRegion(); got != tt.want {
t.Errorf("GetRegion() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,59 @@
// Copyright (c) STACKIT
package features
import (
"context"
"fmt"
"os"
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
// BetaResourcesEnabled returns whether this provider has beta functionality enabled.
//
// In order of precedence, beta functionality can be managed by:
// - Environment Variable `STACKIT_TF_ENABLE_BETA_RESOURCES` - `true` is enabled, `false` is disabled.
// - Provider configuration feature flag `enable_beta` - `true` is enabled, `false` is disabled.
func BetaResourcesEnabled(ctx context.Context, data *core.ProviderData, diags *diag.Diagnostics) bool {
value, set := os.LookupEnv("STACKIT_TF_ENABLE_BETA_RESOURCES")
if set {
if strings.EqualFold(value, "true") {
return true
}
if strings.EqualFold(value, "false") {
return false
}
warnDetails := fmt.Sprintf(`The value of the environment variable that enables beta functionality must be either "true" or "false", got %q.
Defaulting to the provider feature flag.`, value)
core.LogAndAddWarning(ctx, diags, "Invalid value for STACKIT_TF_ENABLE_BETA_RESOURCES environment variable.", warnDetails)
}
// ProviderData should always be set, but we check just in case
if data == nil {
return false
}
return data.EnableBetaResources
}
// CheckBetaResourcesEnabled is a helper function to log and add a warning or error if the beta functionality is not enabled.
//
// Should be called in the Configure method of a beta resource.
// Then, check for Errors in the diags using the diags.HasError() method.
func CheckBetaResourcesEnabled(ctx context.Context, data *core.ProviderData, diags *diag.Diagnostics, resourceName string, resourceType core.ResourceType) {
if !BetaResourcesEnabled(ctx, data, diags) {
core.LogAndAddErrorBeta(ctx, diags, resourceName, resourceType)
return
}
core.LogAndAddWarningBeta(ctx, diags, resourceName, resourceType)
}
func AddBetaDescription(description string, resourceType core.ResourceType) string {
// Callout block: https://developer.hashicorp.com/terraform/registry/providers/docs#callouts
return fmt.Sprintf("%s\n\n~> %s %s",
description,
fmt.Sprintf("This %s is in beta and may be subject to breaking changes in the future. Use with caution.", resourceType),
"See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.",
)
}

View file

@ -0,0 +1,218 @@
// Copyright (c) STACKIT
package features
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
func TestBetaResourcesEnabled(t *testing.T) {
tests := []struct {
description string
data *core.ProviderData
envSet bool
envValue string
expected bool
expectWarn bool
}{
{
description: "Feature flag enabled, env var not set",
data: &core.ProviderData{
EnableBetaResources: true,
},
expected: true,
},
{
description: "Feature flag is disabled, env var not set",
data: &core.ProviderData{
EnableBetaResources: false,
},
expected: false,
},
{
description: "Feature flag, Env var not set",
data: &core.ProviderData{},
expected: false,
},
{
description: "Feature flag not set, Env var is true",
data: &core.ProviderData{},
envSet: true,
envValue: "true",
expected: true,
},
{
description: "Feature flag not set, Env var is false",
data: &core.ProviderData{},
envSet: true,
envValue: "false",
expected: false,
},
{
description: "Feature flag not set, Env var is empty",
data: &core.ProviderData{},
envSet: true,
envValue: "",
expectWarn: true,
expected: false,
},
{
description: "Feature flag not set, Env var is gibberish",
data: &core.ProviderData{},
envSet: true,
envValue: "gibberish",
expectWarn: true,
expected: false,
},
{
description: "Feature flag enabled, Env var is true",
data: &core.ProviderData{
EnableBetaResources: true,
},
envSet: true,
envValue: "true",
expected: true,
},
{
description: "Feature flag enabled, Env var is false",
data: &core.ProviderData{
EnableBetaResources: true,
},
envSet: true,
envValue: "false",
expected: false,
},
{
description: "Feature flag enabled, Env var is empty",
data: &core.ProviderData{
EnableBetaResources: true,
},
envSet: true,
envValue: "",
expectWarn: true,
expected: true,
},
{
description: "Feature flag enabled, Env var is gibberish",
data: &core.ProviderData{
EnableBetaResources: true,
},
envSet: true,
envValue: "gibberish",
expectWarn: true,
expected: true,
},
{
description: "Feature flag disabled, Env var is true",
data: &core.ProviderData{
EnableBetaResources: false,
},
envSet: true,
envValue: "true",
expected: true,
},
{
description: "Feature flag disabled, Env var is false",
data: &core.ProviderData{
EnableBetaResources: false,
},
envSet: true,
envValue: "false",
expected: false,
},
{
description: "Feature flag disabled, Env var is empty",
data: &core.ProviderData{
EnableBetaResources: false,
},
envSet: true,
envValue: "",
expectWarn: true,
expected: false,
},
{
description: "Feature flag disabled, Env var is gibberish",
data: &core.ProviderData{
EnableBetaResources: false,
},
envSet: true,
envValue: "gibberish",
expectWarn: true,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
if tt.envSet {
t.Setenv("STACKIT_TF_ENABLE_BETA_RESOURCES", tt.envValue)
}
diags := diag.Diagnostics{}
result := BetaResourcesEnabled(context.Background(), tt.data, &diags)
if result != tt.expected {
t.Fatalf("Expected %t, got %t", tt.expected, result)
}
if tt.expectWarn && diags.WarningsCount() == 0 {
t.Fatalf("Expected warning, got none")
}
if !tt.expectWarn && diags.WarningsCount() > 0 {
t.Fatalf("Expected no warning, got %d", diags.WarningsCount())
}
})
}
}
func TestCheckBetaResourcesEnabled(t *testing.T) {
tests := []struct {
description string
betaEnabled bool
expectError bool
expectWarn bool
}{
{
description: "Beta enabled, show warning",
betaEnabled: true,
expectWarn: true,
},
{
description: "Beta disabled, show error",
betaEnabled: false,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
var envValue string
if tt.betaEnabled {
envValue = "true"
} else {
envValue = "false"
}
t.Setenv("STACKIT_TF_ENABLE_BETA_RESOURCES", envValue)
diags := diag.Diagnostics{}
CheckBetaResourcesEnabled(context.Background(), &core.ProviderData{}, &diags, "stackit_test", "resource")
if tt.expectError && diags.ErrorsCount() == 0 {
t.Fatalf("Expected error, got none")
}
if !tt.expectError && diags.ErrorsCount() > 0 {
t.Fatalf("Expected no error, got %d", diags.ErrorsCount())
}
if tt.expectWarn && diags.WarningsCount() == 0 {
t.Fatalf("Expected warning, got none")
}
if !tt.expectWarn && diags.WarningsCount() > 0 {
t.Fatalf("Expected no warning, got %d", diags.WarningsCount())
}
})
}
}

View file

@ -0,0 +1,78 @@
// Copyright (c) STACKIT
package features
import (
"context"
"fmt"
"slices"
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
const (
RoutingTablesExperiment = "routing-tables"
NetworkExperiment = "network"
IamExperiment = "iam"
)
var AvailableExperiments = []string{IamExperiment, RoutingTablesExperiment, NetworkExperiment}
// Check if an experiment is valid.
func ValidExperiment(experiment string, diags *diag.Diagnostics) bool {
validExperiment := slices.ContainsFunc(AvailableExperiments, func(e string) bool {
return strings.EqualFold(e, experiment)
})
if !validExperiment {
diags.AddError("Invalid Experiment", fmt.Sprintf("The Experiment %s is invalid. This is most likely a bug in the STACKIT Provider. Please open an issue. Available Experiments: %v", experiment, AvailableExperiments))
}
return validExperiment
}
// Check if an experiment is enabled.
func CheckExperimentEnabled(ctx context.Context, data *core.ProviderData, experiment, resourceName string, resourceType core.ResourceType, diags *diag.Diagnostics) {
if CheckExperimentEnabledWithoutError(ctx, data, experiment, resourceName, resourceType, diags) {
return
}
errTitle := fmt.Sprintf("%s is part of the %s experiment, which is currently disabled by default", resourceName, experiment)
errContent := fmt.Sprintf(`Enable the %s experiment by adding it into your provider block.`, experiment)
tflog.Error(ctx, fmt.Sprintf("%s | %s", errTitle, errContent))
diags.AddError(errTitle, errContent)
}
func CheckExperimentEnabledWithoutError(ctx context.Context, data *core.ProviderData, experiment, resourceName string, resourceType core.ResourceType, diags *diag.Diagnostics) bool {
if !ValidExperiment(experiment, diags) {
errTitle := fmt.Sprintf("The experiment %s does not exist.", experiment)
errContent := "This is a bug in the STACKIT Terraform Provider. Please open an issue here: https://github.com/stackitcloud/terraform-provider-stackit/issues"
diags.AddError(errTitle, errContent)
return false
}
experimentActive := slices.ContainsFunc(data.Experiments, func(e string) bool {
return strings.EqualFold(e, experiment)
})
if experimentActive {
warnTitle := fmt.Sprintf("%s is part of the %s experiment.", resourceName, experiment)
warnContent := fmt.Sprintf("This %s is part of the %s experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.", resourceType, experiment)
tflog.Warn(ctx, fmt.Sprintf("%s | %s", warnTitle, warnContent))
diags.AddWarning(warnTitle, warnContent)
return true
}
return false
}
func AddExperimentDescription(description, experiment string, resourceType core.ResourceType) string {
// Callout block: https://developer.hashicorp.com/terraform/registry/providers/docs#callouts
return fmt.Sprintf("%s\n\n~> %s%s%s%s%s",
description,
"This ",
resourceType,
" is part of the ",
experiment,
" experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.",
)
}

View file

@ -0,0 +1,254 @@
// Copyright (c) STACKIT
package features
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
func TestValidExperiment(t *testing.T) {
type args struct {
experiment string
diags *diag.Diagnostics
}
tests := []struct {
name string
args args
want bool
}{
{
name: "valid",
args: args{
experiment: IamExperiment,
diags: &diag.Diagnostics{},
},
want: true,
},
{
name: "invalid",
args: args{
experiment: "foo",
diags: &diag.Diagnostics{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidExperiment(tt.args.experiment, tt.args.diags); got != tt.want {
t.Errorf("ValidExperiment() = %v, want %v", got, tt.want)
}
})
}
}
func TestCheckExperimentEnabled(t *testing.T) {
type args struct {
ctx context.Context
data *core.ProviderData
experiment string
resourceName string
resourceType core.ResourceType
diags *diag.Diagnostics
}
tests := []struct {
name string
args args
wantDiagsErr bool
wantDiagsWarning bool
}{
{
name: "enabled",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment},
},
experiment: IamExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantDiagsErr: false,
wantDiagsWarning: true,
},
{
name: "disabled",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{},
},
experiment: IamExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantDiagsErr: true,
wantDiagsWarning: false,
},
{
name: "invalid experiment",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment},
},
experiment: "foobar",
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantDiagsErr: true,
wantDiagsWarning: false,
},
{
name: "enabled multiple experiment",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment, NetworkExperiment, RoutingTablesExperiment},
},
experiment: NetworkExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantDiagsErr: false,
wantDiagsWarning: true,
},
{
name: "enabled multiple experiment - without the required experiment",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment, RoutingTablesExperiment},
},
experiment: NetworkExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantDiagsErr: true,
wantDiagsWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
CheckExperimentEnabled(tt.args.ctx, tt.args.data, tt.args.experiment, tt.args.resourceName, tt.args.resourceType, tt.args.diags)
if got := tt.args.diags.HasError(); got != tt.wantDiagsErr {
t.Errorf("CheckExperimentEnabled() diags.HasError() = %v, want %v", got, tt.wantDiagsErr)
}
if got := tt.args.diags.WarningsCount() > 0; got != tt.wantDiagsWarning {
t.Errorf("CheckExperimentEnabled() diags.WarningsCount() > 0 = %v, want %v", got, tt.wantDiagsErr)
}
})
}
}
func TestCheckExperimentEnabledWithoutError(t *testing.T) {
type args struct {
ctx context.Context
data *core.ProviderData
experiment string
resourceName string
resourceType core.ResourceType
diags *diag.Diagnostics
}
tests := []struct {
name string
args args
wantEnabled bool
wantDiagsErr bool
wantDiagsWarning bool
}{
{
name: "enabled",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment},
},
experiment: IamExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantEnabled: true,
wantDiagsErr: false,
wantDiagsWarning: true,
},
{
name: "disabled - no error",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{},
},
experiment: NetworkExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantEnabled: false,
wantDiagsErr: false,
wantDiagsWarning: false,
},
{
name: "invalid experiment",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment},
},
experiment: "foobar",
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantEnabled: false,
wantDiagsErr: true,
wantDiagsWarning: false,
},
{
name: "enabled multiple experiment",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment, NetworkExperiment, RoutingTablesExperiment},
},
experiment: NetworkExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantEnabled: true,
wantDiagsErr: false,
wantDiagsWarning: true,
},
{
name: "enabled multiple experiment - without the required experiment",
args: args{
ctx: context.Background(),
data: &core.ProviderData{
Experiments: []string{IamExperiment, RoutingTablesExperiment},
},
experiment: NetworkExperiment,
resourceType: core.Resource,
diags: &diag.Diagnostics{},
},
wantEnabled: false,
wantDiagsErr: false,
wantDiagsWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := CheckExperimentEnabledWithoutError(tt.args.ctx, tt.args.data, tt.args.experiment, tt.args.resourceName, tt.args.resourceType, tt.args.diags); got != tt.wantEnabled {
t.Errorf("CheckExperimentEnabledWithoutError() = %v, want %v", got, tt.wantEnabled)
}
if got := tt.args.diags.HasError(); got != tt.wantDiagsErr {
t.Errorf("CheckExperimentEnabled() diags.HasError() = %v, want %v", got, tt.wantDiagsErr)
}
if got := tt.args.diags.WarningsCount() > 0; got != tt.wantDiagsWarning {
t.Errorf("CheckExperimentEnabled() diags.WarningsCount() > 0 = %v, want %v", got, tt.wantDiagsErr)
}
})
}
}

View file

@ -0,0 +1,171 @@
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,447 @@
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

@ -0,0 +1,192 @@
// 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,222 @@
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,893 @@
package postgresflex
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/pkg/postgresflexalpha/wait"
postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflexalpha/utils"
"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/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"github.com/hashicorp/terraform-plugin-framework/path"
"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 = &instanceResource{}
_ resource.ResourceWithConfigure = &instanceResource{}
_ resource.ResourceWithImportState = &instanceResource{}
_ resource.ResourceWithModifyPlan = &instanceResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
ACL types.List `tfsdk:"acl"`
BackupSchedule types.String `tfsdk:"backup_schedule"`
Flavor types.Object `tfsdk:"flavor"`
Replicas types.Int64 `tfsdk:"replicas"`
Storage types.Object `tfsdk:"storage"`
Version types.String `tfsdk:"version"`
Region types.String `tfsdk:"region"`
Encryption types.Object `tfsdk:"encryption"`
Network types.Object `tfsdk:"network"`
}
type encryptionModel struct {
KeyRingId types.String `tfsdk:"keyring_id"`
KeyId types.String `tfsdk:"key_id"`
KeyVersion types.String `tfsdk:"key_version"`
ServiceAccount types.String `tfsdk:"service_account"`
}
var encryptionTypes = map[string]attr.Type{
"keyring_id": basetypes.StringType{},
"key_id": basetypes.StringType{},
"key_version": basetypes.StringType{},
"service_account": basetypes.StringType{},
}
type networkModel struct {
AccessScope types.String `tfsdk:"access_scope"`
}
var networkTypes = map[string]attr.Type{
"access_scope": basetypes.StringType{},
}
// Struct corresponding to Model.Flavor
type flavorModel struct {
Id types.String `tfsdk:"id"`
Description types.String `tfsdk:"description"`
CPU types.Int64 `tfsdk:"cpu"`
RAM types.Int64 `tfsdk:"ram"`
}
// Types corresponding to flavorModel
var flavorTypes = map[string]attr.Type{
"id": basetypes.StringType{},
"description": basetypes.StringType{},
"cpu": basetypes.Int64Type{},
"ram": basetypes.Int64Type{},
}
// Struct corresponding to Model.Storage
type storageModel struct {
Class types.String `tfsdk:"class"`
Size types.Int64 `tfsdk:"size"`
}
// Types corresponding to storageModel
var storageTypes = map[string]attr.Type{
"class": basetypes.StringType{},
"size": basetypes.Int64Type{},
}
// NewInstanceResource is a helper function to simplify the provider implementation.
func NewInstanceResource() resource.Resource {
return &instanceResource{}
}
// instanceResource is the resource implementation.
type instanceResource 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 *instanceResource) 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 *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_instance"
}
// Configure adds the provider configured client to the resource.
func (r *instanceResource) 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 instance client configured")
}
// Schema defines the schema for the resource.
func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex instance 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`\".",
"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.",
"encryption": "The encryption block.",
"key_id": "Key ID of the encryption key.",
}
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(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Computed: true,
PlanModifiers: []planmodifier.String{
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,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.RegexMatches(
regexp.MustCompile("^[a-z]([-a-z0-9]*[a-z0-9])?$"),
"must start with a letter, must have lower case letters, numbers or hyphens, and no hyphen at the end",
),
},
},
"acl": schema.ListAttribute{
Description: descriptions["acl"],
ElementType: types.StringType,
Required: true,
},
"backup_schedule": schema.StringAttribute{
Required: true,
},
"flavor": schema.SingleNestedAttribute{
Required: true,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
UseStateForUnknownIfFlavorUnchanged(req),
},
},
"description": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
UseStateForUnknownIfFlavorUnchanged(req),
},
},
"cpu": schema.Int64Attribute{
Required: true,
},
"ram": schema.Int64Attribute{
Required: true,
},
},
},
"replicas": schema.Int64Attribute{
Required: true,
},
"storage": schema.SingleNestedAttribute{
Required: true,
Attributes: map[string]schema.Attribute{
"class": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"size": schema.Int64Attribute{
Required: true,
},
},
},
"version": schema.StringAttribute{
Required: true,
},
"region": schema.StringAttribute{
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
Description: descriptions["region"],
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"encryption": schema.SingleNestedAttribute{
Required: true,
Attributes: map[string]schema.Attribute{
"key_id": schema.StringAttribute{
Description: descriptions["key_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"key_version": schema.StringAttribute{
Description: descriptions["key_version"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"key_ring_id": schema.StringAttribute{
Description: descriptions["key_ring_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"service_account": schema.StringAttribute{
Description: descriptions["service_account"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
},
//Blocks: nil,
//CustomType: nil,
Description: descriptions["encryption"],
//MarkdownDescription: "",
//DeprecationMessage: "",
//Validators: nil,
PlanModifiers: []planmodifier.Object{},
},
"network": schema.SingleNestedAttribute{
Required: true,
Attributes: map[string]schema.Attribute{
"access_scope": schema.StringAttribute{
Description: descriptions["access_scope"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
},
//Blocks: nil,
//CustomType: nil,
Description: descriptions["network"],
//MarkdownDescription: "",
//DeprecationMessage: "",
//Validators: nil,
PlanModifiers: []planmodifier.Object{},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
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()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
var acl []string
if !(model.ACL.IsNull() || model.ACL.IsUnknown()) {
diags = model.ACL.ElementsAs(ctx, &acl, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
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
}
err := loadFlavorId(ctx, r.client, &model, flavor)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Loading flavor ID: %v", err))
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
}
}
var encryption = &encryptionModel{}
if !(model.Encryption.IsNull() || model.Encryption.IsUnknown()) {
diags = model.Encryption.As(ctx, encryption, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var network = &networkModel{}
if !(model.Network.IsNull() || model.Network.IsUnknown()) {
diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(&model, acl, flavor, storage, encryption, network)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new instance
createResp, err := r.client.CreateInstanceRequest(ctx, projectId, region).CreateInstanceRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
instanceId := *createResp.Id
ctx = tflog.SetField(ctx, "instance_id", instanceId)
waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, region, instanceId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance creation waiting: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, waitResp, &model, flavor, storage, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", 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 instance created")
}
// Read refreshes the Terraform state with the latest data.
func (r *instanceResource) 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()
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)
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
}
}
instanceResp, err := r.client.GetInstanceRequest(ctx, projectId, region, instanceId).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 {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", err.Error())
return
}
ctx = core.LogResponse(ctx)
if instanceResp != nil && instanceResp.Status != nil && *instanceResp.Status == wait.InstanceStateDeleted {
resp.State.RemoveResource(ctx)
return
}
// Map response body to schema
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")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
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()
instanceId := model.InstanceId.ValueString()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
var acl []string
if !(model.ACL.IsNull() || model.ACL.IsUnknown()) {
diags = model.ACL.ElementsAs(ctx, &acl, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
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
}
err := loadFlavorId(ctx, r.client, &model, flavor)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Loading flavor ID: %v", err))
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
}
}
// Generate API request body from model
payload, err := toUpdatePayload(&model, acl, flavor, storage)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing instance
err = r.client.UpdateInstancePartiallyRequest(ctx, projectId, region, instanceId).UpdateInstancePartiallyRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", err.Error())
return
}
ctx = core.LogResponse(ctx)
waitResp, err := wait.PartialUpdateInstanceWaitHandler(ctx, r.client, projectId, region, instanceId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Instance update waiting: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, waitResp, &model, flavor, storage, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgresflex instance updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
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()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
// Delete existing instance
err := r.client.DeleteInstanceRequest(ctx, projectId, region, instanceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteInstanceWaitHandler(ctx, r.client, projectId, region, instanceId).SetTimeout(45 * time.Minute).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Instance deletion waiting: %v", err))
return
}
tflog.Info(ctx, "Postgres Flex instance deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,instance_id
func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing instance",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[instance_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])...)
tflog.Info(ctx, "Postgres Flex instance state imported")
}
func mapFields(ctx context.Context, resp *postgresflexalpha.GetInstanceResponse, model *Model, flavor *flavorModel, storage *storageModel, region string) error {
if resp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
instance := resp
var instanceId string
if model.InstanceId.ValueString() != "" {
instanceId = model.InstanceId.ValueString()
} else if instance.Id != nil {
instanceId = *instance.Id
} else {
return fmt.Errorf("instance id not present")
}
var aclList basetypes.ListValue
var diags diag.Diagnostics
if instance.Acl == nil {
aclList = types.ListNull(types.StringType)
} else {
respACL := *instance.Acl
modelACL, err := utils.ListValuetoStringSlice(model.ACL)
if err != nil {
return err
}
reconciledACL := utils.ReconcileStringSlices(modelACL, respACL)
aclList, diags = types.ListValueFrom(ctx, types.StringType, reconciledACL)
if diags.HasError() {
return fmt.Errorf("mapping ACL: %w", core.DiagsToError(diags))
}
}
var flavorValues map[string]attr.Value
if instance.FlavorId == nil {
flavorValues = map[string]attr.Value{
"id": flavor.Id,
"description": flavor.Description,
"cpu": flavor.CPU,
"ram": flavor.RAM,
}
} else {
// TODO
//flavorValues = map[string]attr.Value{
// "id": types.StringValue(*instance.FlavorId),
// "description": types.StringValue(*instance.Flavor.Description),
// "cpu": types.Int64PointerValue(instance.Flavor.Cpu),
// "ram": types.Int64PointerValue(instance.Flavor.Memory),
//}
}
flavorObject, diags := types.ObjectValue(flavorTypes, flavorValues)
if diags.HasError() {
return fmt.Errorf("creating flavor: %w", core.DiagsToError(diags))
}
var storageValues map[string]attr.Value
if instance.Storage == nil {
storageValues = map[string]attr.Value{
"class": storage.Class,
"size": storage.Size,
}
} else {
storageValues = map[string]attr.Value{
"class": types.StringValue(*instance.Storage.PerformanceClass),
"size": types.Int64PointerValue(instance.Storage.Size),
}
}
storageObject, diags := types.ObjectValue(storageTypes, storageValues)
if diags.HasError() {
return fmt.Errorf("creating storage: %w", core.DiagsToError(diags))
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, instanceId)
model.InstanceId = types.StringValue(instanceId)
model.Name = types.StringPointerValue(instance.Name)
model.ACL = aclList
model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule)
model.Flavor = flavorObject
// TODO - verify working
model.Replicas = types.Int64Value(int64(*instance.Replicas))
model.Storage = storageObject
model.Version = types.StringPointerValue(instance.Version)
model.Region = types.StringValue(region)
//model.Encryption = types.ObjectValue()
//model.Network = networkModel
return nil
}
func toCreatePayload(model *Model, acl []string, flavor *flavorModel, storage *storageModel, enc *encryptionModel, net *networkModel) (*postgresflexalpha.CreateInstanceRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if acl == nil {
return nil, fmt.Errorf("nil acl")
}
if flavor == nil {
return nil, fmt.Errorf("nil flavor")
}
if storage == nil {
return nil, fmt.Errorf("nil storage")
}
replVal := int32(model.Replicas.ValueInt64())
return &postgresflexalpha.CreateInstanceRequestPayload{
// TODO - verify working
Acl: &[]string{
strings.Join(acl, ","),
},
BackupSchedule: conversion.StringValueToPointer(model.BackupSchedule),
FlavorId: conversion.StringValueToPointer(flavor.Id),
Name: conversion.StringValueToPointer(model.Name),
// TODO - verify working
Replicas: postgresflexalpha.CreateInstanceRequestPayloadGetReplicasAttributeType(&replVal),
// TODO - verify working
Storage: postgresflexalpha.CreateInstanceRequestPayloadGetStorageAttributeType(&postgresflexalpha.Storage{
PerformanceClass: conversion.StringValueToPointer(storage.Class),
Size: conversion.Int64ValueToPointer(storage.Size),
}),
Version: conversion.StringValueToPointer(model.Version),
// TODO - verify working
Encryption: postgresflexalpha.CreateInstanceRequestPayloadGetEncryptionAttributeType(
&postgresflexalpha.InstanceEncryption{
KekKeyId: conversion.StringValueToPointer(enc.KeyId), // model.Encryption.Attributes(),
KekKeyRingId: conversion.StringValueToPointer(enc.KeyRingId),
KekKeyVersion: conversion.StringValueToPointer(enc.KeyVersion),
ServiceAccount: conversion.StringValueToPointer(enc.ServiceAccount),
},
),
Network: &postgresflexalpha.InstanceNetwork{
AccessScope: postgresflexalpha.InstanceNetworkGetAccessScopeAttributeType(
conversion.StringValueToPointer(net.AccessScope),
),
},
}, nil
}
func toUpdatePayload(model *Model, acl []string, flavor *flavorModel, storage *storageModel) (*postgresflexalpha.UpdateInstancePartiallyRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if acl == nil {
return nil, fmt.Errorf("nil acl")
}
if flavor == nil {
return nil, fmt.Errorf("nil flavor")
}
if storage == nil {
return nil, fmt.Errorf("nil storage")
}
return &postgresflexalpha.UpdateInstancePartiallyRequestPayload{
//Acl: postgresflexalpha.UpdateInstancePartiallyRequestPayloadGetAclAttributeType{
// Items: &acl,
//},
BackupSchedule: conversion.StringValueToPointer(model.BackupSchedule),
FlavorId: conversion.StringValueToPointer(flavor.Id),
Name: conversion.StringValueToPointer(model.Name),
//Replicas: conversion.Int64ValueToPointer(model.Replicas),
Storage: &postgresflexalpha.StorageUpdate{
Size: conversion.Int64ValueToPointer(storage.Size),
},
Version: conversion.StringValueToPointer(model.Version),
}, nil
}
type postgresflexClient interface {
GetFlavorsRequestExecute(ctx context.Context, projectId string, region string) (*postgresflexalpha.GetFlavorsResponse, error)
}
func loadFlavorId(ctx context.Context, client postgresflexClient, model *Model, flavor *flavorModel) error {
if model == nil {
return fmt.Errorf("nil model")
}
if flavor == nil {
return fmt.Errorf("nil flavor")
}
cpu := conversion.Int64ValueToPointer(flavor.CPU)
if cpu == nil {
return fmt.Errorf("nil CPU")
}
ram := conversion.Int64ValueToPointer(flavor.RAM)
if ram == nil {
return fmt.Errorf("nil RAM")
}
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
res, err := client.GetFlavorsRequestExecute(ctx, projectId, region)
if err != nil {
return fmt.Errorf("listing postgresflex flavors: %w", err)
}
avl := ""
if res.Flavors == nil {
return fmt.Errorf("finding flavors for project %s", projectId)
}
for _, f := range *res.Flavors {
if f.Id == nil || f.Cpu == nil || f.Memory == nil {
continue
}
if *f.Cpu == *cpu && *f.Memory == *ram {
flavor.Id = types.StringValue(*f.Id)
flavor.Description = types.StringValue(*f.Description)
break
}
avl = fmt.Sprintf("%s\n- %d CPU, %d GB RAM", avl, *f.Cpu, *f.Memory)
}
if flavor.Id.ValueString() == "" {
return fmt.Errorf("couldn't find flavor, available specs are:%s", avl)
}
return nil
}

View file

@ -0,0 +1,768 @@
// 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

@ -0,0 +1,87 @@
// 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,371 @@
// Copyright (c) STACKIT
package postgresflex_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
// Instance resource data
var instanceResource = map[string]string{
"project_id": testutil.ProjectId,
"name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum)),
"acl": "192.168.0.0/16",
"backup_schedule": "00 16 * * *",
"backup_schedule_updated": "00 12 * * *",
"flavor_cpu": "2",
"flavor_ram": "4",
"flavor_description": "Small, Compute optimized",
"replicas": "1",
"storage_class": "premium-perf12-stackit",
"storage_size": "5",
"version": "14",
"flavor_id": "2.4",
}
// User resource data
var userResource = map[string]string{
"username": fmt.Sprintf("tfaccuser%s", acctest.RandStringFromCharSet(4, acctest.CharSetAlpha)),
"role": "createdb",
"project_id": instanceResource["project_id"],
}
// Database resource data
var databaseResource = map[string]string{
"name": fmt.Sprintf("tfaccdb%s", acctest.RandStringFromCharSet(4, acctest.CharSetAlphaNum)),
}
func configResources(backupSchedule string, region *string) string {
var regionConfig string
if region != nil {
regionConfig = fmt.Sprintf(`region = %q`, *region)
}
return fmt.Sprintf(`
%s
resource "stackit_postgresflex_instance" "instance" {
project_id = "%s"
name = "%s"
acl = ["%s"]
backup_schedule = "%s"
flavor = {
cpu = %s
ram = %s
}
replicas = %s
storage = {
class = "%s"
size = %s
}
version = "%s"
%s
}
resource "stackit_postgresflex_user" "user" {
project_id = stackit_postgresflex_instance.instance.project_id
instance_id = stackit_postgresflex_instance.instance.instance_id
username = "%s"
roles = ["%s"]
}
resource "stackit_postgresflex_database" "database" {
project_id = stackit_postgresflex_instance.instance.project_id
instance_id = stackit_postgresflex_instance.instance.instance_id
name = "%s"
owner = stackit_postgresflex_user.user.username
}
`,
testutil.PostgresFlexProviderConfig(),
instanceResource["project_id"],
instanceResource["name"],
instanceResource["acl"],
backupSchedule,
instanceResource["flavor_cpu"],
instanceResource["flavor_ram"],
instanceResource["replicas"],
instanceResource["storage_class"],
instanceResource["storage_size"],
instanceResource["version"],
regionConfig,
userResource["username"],
userResource["role"],
databaseResource["name"],
)
}
func TestAccPostgresFlexFlexResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckPostgresFlexDestroy,
Steps: []resource.TestStep{
// Creation
{
Config: configResources(instanceResource["backup_schedule"], &testutil.Region),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", instanceResource["project_id"]),
resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", instanceResource["name"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]),
resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.description"),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "replicas", instanceResource["replicas"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", instanceResource["storage_class"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", instanceResource["storage_size"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", instanceResource["version"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "region", testutil.Region),
// User
resource.TestCheckResourceAttrPair(
"stackit_postgresflex_user.user", "project_id",
"stackit_postgresflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_postgresflex_user.user", "instance_id",
"stackit_postgresflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "user_id"),
resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "password"),
// Database
resource.TestCheckResourceAttrPair(
"stackit_postgresflex_database.database", "project_id",
"stackit_postgresflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_postgresflex_database.database", "instance_id",
"stackit_postgresflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttr("stackit_postgresflex_database.database", "name", databaseResource["name"]),
resource.TestCheckResourceAttrPair(
"stackit_postgresflex_database.database", "owner",
"stackit_postgresflex_user.user", "username",
),
),
},
// data source
{
Config: fmt.Sprintf(`
%s
data "stackit_postgresflex_instance" "instance" {
project_id = stackit_postgresflex_instance.instance.project_id
instance_id = stackit_postgresflex_instance.instance.instance_id
}
data "stackit_postgresflex_user" "user" {
project_id = stackit_postgresflex_instance.instance.project_id
instance_id = stackit_postgresflex_instance.instance.instance_id
user_id = stackit_postgresflex_user.user.user_id
}
data "stackit_postgresflex_database" "database" {
project_id = stackit_postgresflex_instance.instance.project_id
instance_id = stackit_postgresflex_instance.instance.instance_id
database_id = stackit_postgresflex_database.database.database_id
}
`,
configResources(instanceResource["backup_schedule"], nil),
),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance data
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "project_id", instanceResource["project_id"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "name", instanceResource["name"]),
resource.TestCheckResourceAttrPair(
"data.stackit_postgresflex_instance.instance", "project_id",
"stackit_postgresflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_postgresflex_instance.instance", "instance_id",
"stackit_postgresflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_postgresflex_user.user", "instance_id",
"stackit_postgresflex_user.user", "instance_id",
),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.id", instanceResource["flavor_id"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.description", instanceResource["flavor_description"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "replicas", instanceResource["replicas"]),
// User data
resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "project_id", userResource["project_id"]),
resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "user_id"),
resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "username", userResource["username"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "roles.#", "1"),
resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "roles.0", userResource["role"]),
resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "host"),
resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "port"),
// Database data
resource.TestCheckResourceAttr("data.stackit_postgresflex_database.database", "project_id", instanceResource["project_id"]),
resource.TestCheckResourceAttr("data.stackit_postgresflex_database.database", "name", databaseResource["name"]),
resource.TestCheckResourceAttrPair(
"data.stackit_postgresflex_database.database", "instance_id",
"stackit_postgresflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_postgresflex_database.database", "owner",
"data.stackit_postgresflex_user.user", "username",
),
),
},
// Import
{
ResourceName: "stackit_postgresflex_instance.instance",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_postgresflex_instance.instance"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_postgresflex_instance.instance")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"password"},
},
{
ResourceName: "stackit_postgresflex_user.user",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_postgresflex_user.user"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_postgresflex_user.user")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
userId, ok := r.Primary.Attributes["user_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute user_id")
}
return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId, userId), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"password", "uri"},
},
{
ResourceName: "stackit_postgresflex_database.database",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_postgresflex_database.database"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_postgresflex_database.database")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
databaseId, ok := r.Primary.Attributes["database_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute database_id")
}
return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId, databaseId), nil
},
ImportState: true,
ImportStateVerify: true,
},
// Update
{
Config: configResources(instanceResource["backup_schedule_updated"], nil),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance data
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", instanceResource["project_id"]),
resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", instanceResource["name"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule_updated"]),
resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.description"),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "replicas", instanceResource["replicas"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", instanceResource["storage_class"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", instanceResource["storage_size"]),
resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", instanceResource["version"]),
),
},
// Deletion is done by the framework implicitly
},
})
}
func testAccCheckPostgresFlexDestroy(s *terraform.State) error {
ctx := context.Background()
var client *postgresflex.APIClient
var err error
if testutil.PostgresFlexCustomEndpoint == "" {
client, err = postgresflex.NewAPIClient()
} else {
client, err = postgresflex.NewAPIClient(
config.WithEndpoint(testutil.PostgresFlexCustomEndpoint),
)
}
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
instancesToDestroy := []string{}
for _, rs := range s.RootModule().Resources {
if rs.Type != "stackit_postgresflex_instance" {
continue
}
// instance terraform ID: = "[project_id],[region],[instance_id]"
instanceId := strings.Split(rs.Primary.ID, core.Separator)[2]
instancesToDestroy = append(instancesToDestroy, instanceId)
}
instancesResp, err := client.ListInstances(ctx, testutil.ProjectId, testutil.Region).Execute()
if err != nil {
return fmt.Errorf("getting instancesResp: %w", err)
}
items := *instancesResp.Items
for i := range items {
if items[i].Id == nil {
continue
}
if utils.Contains(instancesToDestroy, *items[i].Id) {
err := client.ForceDeleteInstanceExecute(ctx, testutil.ProjectId, testutil.Region, *items[i].Id)
if err != nil {
return fmt.Errorf("deleting instance %s during CheckDestroy: %w", *items[i].Id, err)
}
_, err = wait.DeleteInstanceWaitHandler(ctx, client, testutil.ProjectId, testutil.Region, *items[i].Id).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("deleting instance %s during CheckDestroy: waiting for deletion %w", *items[i].Id, err)
}
}
}
return nil
}

View file

@ -0,0 +1,232 @@
// Copyright (c) STACKIT
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/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/types"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
// Ensure the implementation satisfies the expected interfaces.
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{}
}
// userDataSource is the data source implementation.
type userDataSource struct {
client *postgresflex.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"
}
// Configure adds the provider configured client to the data source.
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 {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex user client configured")
}
// 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.",
}
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"],
},
},
}
}
// 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
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()
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)
recordSetResp, err := r.client.GetUser(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),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// 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))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
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,
)
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

@ -0,0 +1,146 @@
// 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,579 @@
// Copyright (c) STACKIT
package postgresflexa
import (
"context"
"fmt"
"net/http"
"strings"
postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"github.com/hashicorp/terraform-plugin-framework/attr"
"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"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/wait"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
)
type Model 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"`
Password types.String `tfsdk:"password"`
Host types.String `tfsdk:"host"`
Port types.Int64 `tfsdk:"port"`
Uri types.String `tfsdk:"uri"`
Region types.String `tfsdk:"region"`
}
// NewUserResource is a helper function to simplify the provider implementation.
func NewUserResource() resource.Resource {
return &userResource{}
}
// userResource is the resource implementation.
type userResource struct {
client *postgresflex.APIClient
providerData core.ProviderData
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *userResource) 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 *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflex_user"
}
// Configure adds the provider configured client to the resource.
func (r *userResource) 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 user client configured")
}
// Schema defines the schema for the resource.
func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
rolesOptions := []string{"login", "createdb"}
descriptions := map[string]string{
"main": "Postgres Flex user 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`,`user_id`\".",
"user_id": "User ID.",
"instance_id": "ID of the PostgresFlex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"roles": "Database access levels for the user. " + utils.FormatPossibleValues(rolesOptions...),
"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(),
},
},
"user_id": schema.StringAttribute{
Description: descriptions["user_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(),
},
},
"username": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"roles": schema.SetAttribute{
Description: descriptions["roles"],
ElementType: types.StringType,
Required: true,
Validators: []validator.Set{
setvalidator.ValueStringsAre(
stringvalidator.OneOf("login", "createdb"),
),
},
},
"password": schema.StringAttribute{
Computed: true,
Sensitive: true,
},
"host": schema.StringAttribute{
Computed: true,
},
"port": schema.Int64Attribute{
Computed: true,
},
"uri": schema.StringAttribute{
Computed: true,
Sensitive: true,
},
"region": schema.StringAttribute{
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
Description: descriptions["region"],
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *userResource) 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()
instanceId := model.InstanceId.ValueString()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
var roles []string
if !(model.Roles.IsNull() || model.Roles.IsUnknown()) {
diags = model.Roles.ElementsAs(ctx, &roles, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(&model, roles)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new user
userResp, err := r.client.CreateUser(ctx, projectId, region, instanceId).CreateUserPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if userResp == nil || userResp.Item == nil || userResp.Item.Id == nil || *userResp.Item.Id == "" {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", "API didn't return user Id. A user might have been created")
return
}
userId := *userResp.Item.Id
ctx = tflog.SetField(ctx, "user_id", userId)
// Map response body to schema
err = mapFieldsCreate(userResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", 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 user created")
}
// Read refreshes the Terraform state with the latest data.
func (r *userResource) 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()
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)
recordSetResp, err := r.client.GetUser(ctx, projectId, region, instanceId, userId).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 {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(recordSetResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", 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 user read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
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()
instanceId := model.InstanceId.ValueString()
userId := model.UserId.ValueString()
region := model.Region.ValueString()
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)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
var roles []string
if !(model.Roles.IsNull() || model.Roles.IsUnknown()) {
diags = model.Roles.ElementsAs(ctx, &roles, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toUpdatePayload(&model, roles)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", fmt.Sprintf("Updating API payload: %v", err))
return
}
// Update existing instance
err = r.client.UpdateUser(ctx, projectId, region, instanceId, userId).UpdateUserPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", err.Error())
return
}
ctx = core.LogResponse(ctx)
userResp, err := r.client.GetUser(ctx, projectId, region, instanceId, userId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(userResp, &stateModel, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex user updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *userResource) 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()
userId := model.UserId.ValueString()
region := model.Region.ValueString()
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)
// Delete existing record set
err := r.client.DeleteUser(ctx, projectId, region, instanceId, userId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting user", fmt.Sprintf("Calling API: %v", err))
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteUserWaitHandler(ctx, r.client, projectId, region, instanceId, userId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting user", fmt.Sprintf("Instance deletion waiting: %v", err))
return
}
tflog.Info(ctx, "Postgres Flex user 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 *userResource) 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 user",
fmt.Sprintf("Expected import identifier with format [project_id],[region],[instance_id],[user_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("user_id"), idParts[3])...)
core.LogAndAddWarning(ctx, &resp.Diagnostics,
"Postgresflex user imported with empty password and empty uri",
"The user password and uri are not imported as they are only available upon creation of a new user. The password and uri fields will be empty.",
)
tflog.Info(ctx, "Postgresflex user state imported")
}
func mapFieldsCreate(userResp *postgresflex.CreateUserResponse, model *Model, 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
if user.Id == nil {
return fmt.Errorf("user id not present")
}
userId := *user.Id
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), userId,
)
model.UserId = types.StringValue(userId)
model.Username = types.StringPointerValue(user.Username)
if user.Password == nil {
return fmt.Errorf("user password not present")
}
model.Password = types.StringValue(*user.Password)
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.Uri = types.StringPointerValue(user.Uri)
model.Region = types.StringValue(region)
return nil
}
func mapFields(userResp *postgresflex.GetUserResponse, model *Model, 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,
)
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
}
func toCreatePayload(model *Model, roles []string) (*postgresflex.CreateUserPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if roles == nil {
return nil, fmt.Errorf("nil roles")
}
return &postgresflex.CreateUserPayload{
Roles: &roles,
Username: conversion.StringValueToPointer(model.Username),
}, nil
}
func toUpdatePayload(model *Model, roles []string) (*postgresflex.UpdateUserPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if roles == nil {
return nil, fmt.Errorf("nil roles")
}
return &postgresflex.UpdateUserPayload{
Roles: &roles,
}, nil
}

View file

@ -0,0 +1,472 @@
// 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,34 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"fmt"
"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"
)
func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *postgresflex.APIClient {
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(providerData.RoundTripper),
utils.UserAgentConfigOption(providerData.Version),
}
if providerData.PostgresFlexCustomEndpoint != "" {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.PostgresFlexCustomEndpoint))
} else {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithRegion(providerData.GetRegion()))
}
apiClient, err := postgresflex.NewAPIClient(apiClientConfigOptions...)
if err != nil {
core.LogAndAddError(ctx, diags, "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 nil
}
return apiClient
}

View file

@ -0,0 +1,96 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"os"
"reflect"
"testing"
"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"
)
const (
testVersion = "1.2.3"
testCustomEndpoint = "https://postgresflex-custom-endpoint.api.stackit.cloud"
)
func TestConfigureClient(t *testing.T) {
/* mock authentication by setting service account token env variable */
os.Clearenv()
err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val")
if err != nil {
t.Errorf("error setting env variable: %v", err)
}
type args struct {
providerData *core.ProviderData
}
tests := []struct {
name string
args args
wantErr bool
expected *postgresflex.APIClient
}{
{
name: "default endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
},
},
expected: func() *postgresflex.APIClient {
apiClient, err := postgresflex.NewAPIClient(
config.WithRegion("eu01"),
utils.UserAgentConfigOption(testVersion),
)
if err != nil {
t.Errorf("error configuring client: %v", err)
}
return apiClient
}(),
wantErr: false,
},
{
name: "custom endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
PostgresFlexCustomEndpoint: testCustomEndpoint,
},
},
expected: func() *postgresflex.APIClient {
apiClient, err := postgresflex.NewAPIClient(
utils.UserAgentConfigOption(testVersion),
config.WithEndpoint(testCustomEndpoint),
)
if err != nil {
t.Errorf("error configuring client: %v", err)
}
return apiClient
}(),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
diags := diag.Diagnostics{}
actual := ConfigureClient(ctx, tt.args.providerData, &diags)
if diags.HasError() != tt.wantErr {
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
}
if !reflect.DeepEqual(actual, tt.expected) {
t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected)
}
})
}
}

View file

@ -0,0 +1,295 @@
package sqlserverflex
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
sqlserverflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflexalpha/utils"
sqlserverflex "github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
"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"
)
// 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 *sqlserverflex.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 + "_sqlserverflexalpha_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 := sqlserverflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "SQLServer 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": "SQLServer 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 SQLServer Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Instance name.",
"acl": "The Access Control List (ACL) for the SQLServer Flex instance.",
"backup_schedule": `The backup schedule. Should follow the cron scheduling system format (e.g. "0 0 * * *").`,
"options": "Custom parameters for the SQLServer Flex instance.",
"region": "The resource region. If not defined, the provider region is used.",
// TODO
}
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,
},
"backup_schedule": schema.StringAttribute{
Description: descriptions["backup_schedule"],
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,
},
"status": schema.StringAttribute{
Computed: true,
},
"edition": schema.StringAttribute{
Computed: true,
},
"retention_days": schema.Int64Attribute{
Computed: true,
},
"region": schema.StringAttribute{
// the region cannot be found, so it has to be passed
Optional: true,
Description: descriptions["region"],
},
"encryption": schema.SingleNestedAttribute{
Computed: true,
Attributes: map[string]schema.Attribute{
"key_id": schema.StringAttribute{
Description: descriptions["key_id"],
Computed: true,
},
"key_version": schema.StringAttribute{
Description: descriptions["key_version"],
Computed: true,
},
"keyring_id": schema.StringAttribute{
Description: descriptions["keyring_id"],
Computed: true,
},
"service_account": schema.StringAttribute{
Description: descriptions["service_account"],
Computed: true,
},
},
Description: descriptions["encryption"],
},
"network": schema.SingleNestedAttribute{
Computed: true,
Attributes: map[string]schema.Attribute{
"access_scope": schema.StringAttribute{
Description: descriptions["access_scope"],
Computed: true,
},
"instance_address": schema.StringAttribute{
Description: descriptions["instance_address"],
Computed: true,
},
"router_address": schema.StringAttribute{
Description: descriptions["router_address"],
Computed: true,
},
"acl": schema.ListAttribute{
Description: descriptions["acl"],
ElementType: types.StringType,
Computed: true,
},
},
Description: descriptions["network"],
},
},
}
}
// 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)
var flavor = &flavorModel{}
if model.Flavor.IsNull() || model.Flavor.IsUnknown() {
flavor.Id = types.StringValue(*instanceResp.FlavorId)
if flavor.Id.IsNull() || flavor.Id.IsUnknown() || flavor.Id.String() == "" {
panic("WTF FlavorId can not be null or empty string")
}
err = getFlavorModelById(ctx, r.client, &model, flavor)
if err != nil {
resp.Diagnostics.AddError(err.Error(), err.Error())
return
}
if flavor.CPU.IsNull() || flavor.CPU.IsUnknown() || flavor.CPU.String() == "" {
panic("WTF FlavorId can not be null or empty string")
}
}
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
}
}
var encryption = &encryptionModel{}
if !(model.Encryption.IsNull() || model.Encryption.IsUnknown()) {
diags = model.Encryption.As(ctx, encryption, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var network = &networkModel{}
if !(model.Network.IsNull() || model.Network.IsUnknown()) {
diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
err = mapFields(ctx, instanceResp, &model, flavor, storage, encryption, network, 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, "SQLServer Flex instance read")
}

View file

@ -0,0 +1,430 @@
package sqlserverflex
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
sqlserverflex "github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
type sqlserverflexClient interface {
GetFlavorsRequestExecute(ctx context.Context, projectId, region string, page, size *int64, sort sqlserverflex.FlavorSort) (*sqlserverflex.GetFlavorsResponse, error)
}
func mapFields(ctx context.Context, resp *sqlserverflex.GetInstanceResponse, model *Model, flavor *flavorModel, storage *storageModel, encryption *encryptionModel, network *networkModel, region string) error {
if resp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
instance := resp
var instanceId string
if model.InstanceId.ValueString() != "" {
instanceId = model.InstanceId.ValueString()
} else if instance.Id != nil {
instanceId = *instance.Id
} else {
return fmt.Errorf("instance id not present")
}
var flavorValues map[string]attr.Value
if instance.FlavorId == nil {
return fmt.Errorf("instance has no flavor id")
}
if *instance.FlavorId != flavor.Id.ValueString() {
return fmt.Errorf("instance has different flavor id %s - %s", *instance.FlavorId, flavor.Id.ValueString())
}
flavorValues = map[string]attr.Value{
"id": flavor.Id,
"description": flavor.Description,
"cpu": flavor.CPU,
"ram": flavor.RAM,
"node_type": flavor.NodeType,
}
flavorObject, diags := types.ObjectValue(flavorTypes, flavorValues)
if diags.HasError() {
return fmt.Errorf("creating flavor: %w", core.DiagsToError(diags))
}
var storageValues map[string]attr.Value
if instance.Storage == nil {
storageValues = map[string]attr.Value{
"class": storage.Class,
"size": storage.Size,
}
} else {
storageValues = map[string]attr.Value{
"class": types.StringValue(*instance.Storage.Class),
"size": types.Int64PointerValue(instance.Storage.Size),
}
}
storageObject, diags := types.ObjectValue(storageTypes, storageValues)
if diags.HasError() {
return fmt.Errorf("creating storage: %w", core.DiagsToError(diags))
}
var encryptionValues map[string]attr.Value
if instance.Encryption == nil {
encryptionValues = map[string]attr.Value{
"keyring_id": encryption.KeyRingId,
"key_id": encryption.KeyId,
"key_version": encryption.KeyVersion,
"service_account": encryption.ServiceAccount,
}
} else {
encryptionValues = map[string]attr.Value{
"keyring_id": types.StringValue(*instance.Encryption.KekKeyRingId),
"key_id": types.StringValue(*instance.Encryption.KekKeyId),
"key_version": types.StringValue(*instance.Encryption.KekKeyVersion),
"service_account": types.StringValue(*instance.Encryption.ServiceAccount),
}
}
encryptionObject, diags := types.ObjectValue(encryptionTypes, encryptionValues)
if diags.HasError() {
return fmt.Errorf("creating encryption: %w", core.DiagsToError(diags))
}
var networkValues map[string]attr.Value
if instance.Network == nil {
networkValues = map[string]attr.Value{
"acl": network.ACL,
"access_scope": network.AccessScope,
"instance_address": network.InstanceAddress,
"router_address": network.RouterAddress,
}
} else {
aclList, diags := types.ListValueFrom(ctx, types.StringType, *instance.Network.Acl)
if diags.HasError() {
return fmt.Errorf("creating network (acl list): %w", core.DiagsToError(diags))
}
var routerAddress string
if instance.Network.RouterAddress != nil {
routerAddress = *instance.Network.RouterAddress
diags.AddWarning("field missing while mapping fields", "router_address was empty in API response")
}
if instance.Network.InstanceAddress == nil {
return fmt.Errorf("creating network: no instance address returned")
}
networkValues = map[string]attr.Value{
"acl": aclList,
"access_scope": types.StringValue(string(*instance.Network.AccessScope)),
"instance_address": types.StringValue(*instance.Network.InstanceAddress),
"router_address": types.StringValue(routerAddress),
}
}
networkObject, diags := types.ObjectValue(networkTypes, networkValues)
if diags.HasError() {
return fmt.Errorf("creating network: %w", core.DiagsToError(diags))
}
simplifiedModelBackupSchedule := utils.SimplifyBackupSchedule(model.BackupSchedule.ValueString())
// If the value returned by the API is different from the one in the model after simplification,
// we update the model so that it causes an error in Terraform
if simplifiedModelBackupSchedule != types.StringPointerValue(instance.BackupSchedule).ValueString() {
model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule)
}
if instance.Replicas == nil {
return fmt.Errorf("instance has no replicas set")
}
if instance.RetentionDays == nil {
return fmt.Errorf("instance has no retention days set")
}
if instance.Version == nil {
return fmt.Errorf("instance has no version set")
}
if instance.Edition == nil {
return fmt.Errorf("instance has no edition set")
}
if instance.Status == nil {
return fmt.Errorf("instance has no status set")
}
if instance.IsDeletable == nil {
return fmt.Errorf("instance has no IsDeletable set")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, instanceId)
model.InstanceId = types.StringValue(instanceId)
model.Name = types.StringPointerValue(instance.Name)
model.Flavor = flavorObject
model.Replicas = types.Int64Value(int64(*instance.Replicas))
model.Storage = storageObject
model.Version = types.StringValue(string(*instance.Version))
model.Edition = types.StringValue(string(*instance.Edition))
model.Region = types.StringValue(region)
model.Encryption = encryptionObject
model.Network = networkObject
model.RetentionDays = types.Int64Value(*instance.RetentionDays)
model.Status = types.StringValue(string(*instance.Status))
model.IsDeletable = types.BoolValue(*instance.IsDeletable)
return nil
}
func toCreatePayload(model *Model, storage *storageModel, encryption *encryptionModel, network *networkModel) (*sqlserverflex.CreateInstanceRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if model.Flavor.IsNull() || model.Flavor.IsUnknown() {
return nil, fmt.Errorf("nil flavor")
}
storagePayload := &sqlserverflex.CreateInstanceRequestPayloadGetStorageArgType{}
if storage != nil {
storagePayload.Class = conversion.StringValueToPointer(storage.Class)
storagePayload.Size = conversion.Int64ValueToPointer(storage.Size)
}
encryptionPayload := &sqlserverflex.CreateInstanceRequestPayloadGetEncryptionArgType{}
if encryption != nil {
encryptionPayload.KekKeyId = conversion.StringValueToPointer(encryption.KeyId)
encryptionPayload.KekKeyVersion = conversion.StringValueToPointer(encryption.KeyVersion)
encryptionPayload.KekKeyRingId = conversion.StringValueToPointer(encryption.KeyRingId)
encryptionPayload.ServiceAccount = conversion.StringValueToPointer(encryption.ServiceAccount)
}
networkPayload := &sqlserverflex.CreateInstanceRequestPayloadGetNetworkArgType{}
if network != nil {
networkPayload.AccessScope = sqlserverflex.CreateInstanceRequestPayloadNetworkGetAccessScopeAttributeType(conversion.StringValueToPointer(network.AccessScope))
}
flavorId := ""
if !(model.Flavor.IsNull() || model.Flavor.IsUnknown()) {
modelValues := model.Flavor.Attributes()
if _, ok := modelValues["id"]; !ok {
return nil, fmt.Errorf("flavor has not yet been created")
}
flavorId = strings.Trim(modelValues["id"].String(), "\"")
}
var aclElements []string
if network != nil && !(network.ACL.IsNull() || network.ACL.IsUnknown()) {
aclElements = make([]string, 0, len(network.ACL.Elements()))
diags := network.ACL.ElementsAs(nil, &aclElements, false)
if diags.HasError() {
return nil, fmt.Errorf("creating network: %w", core.DiagsToError(diags))
}
}
return &sqlserverflex.CreateInstanceRequestPayload{
Acl: &aclElements,
BackupSchedule: conversion.StringValueToPointer(model.BackupSchedule),
FlavorId: &flavorId,
Name: conversion.StringValueToPointer(model.Name),
Storage: storagePayload,
Version: sqlserverflex.CreateInstanceRequestPayloadGetVersionAttributeType(conversion.StringValueToPointer(model.Version)),
Encryption: encryptionPayload,
RetentionDays: conversion.Int64ValueToPointer(model.RetentionDays),
Network: networkPayload,
}, nil
}
func toUpdatePayload(model *Model, storage *storageModel, network *networkModel) (*sqlserverflex.UpdateInstancePartiallyRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if model.Flavor.IsNull() || model.Flavor.IsUnknown() {
return nil, fmt.Errorf("nil flavor")
}
var flavorMdl flavorModel
diag := model.Flavor.As(context.Background(), &flavorMdl, basetypes.ObjectAsOptions{
UnhandledNullAsEmpty: true,
UnhandledUnknownAsEmpty: false,
})
if diag.HasError() {
return nil, fmt.Errorf("flavor conversion error: %v", diag.Errors())
}
storagePayload := &sqlserverflex.UpdateInstanceRequestPayloadGetStorageArgType{}
if storage != nil {
storagePayload.Size = conversion.Int64ValueToPointer(storage.Size)
}
var aclElements []string
if network != nil && !(network.ACL.IsNull() || network.ACL.IsUnknown()) {
aclElements = make([]string, 0, len(network.ACL.Elements()))
diags := network.ACL.ElementsAs(nil, &aclElements, false)
if diags.HasError() {
return nil, fmt.Errorf("creating network: %w", core.DiagsToError(diags))
}
}
// TODO - implement network.ACL as soon as it becomes available
replCount := int32(model.Replicas.ValueInt64())
flavorId := flavorMdl.Id.ValueString()
return &sqlserverflex.UpdateInstancePartiallyRequestPayload{
Acl: &aclElements,
BackupSchedule: conversion.StringValueToPointer(model.BackupSchedule),
FlavorId: &flavorId,
Name: conversion.StringValueToPointer(model.Name),
Replicas: sqlserverflex.UpdateInstancePartiallyRequestPayloadGetReplicasAttributeType(&replCount),
RetentionDays: conversion.Int64ValueToPointer(model.RetentionDays),
Storage: storagePayload,
Version: sqlserverflex.UpdateInstancePartiallyRequestPayloadGetVersionAttributeType(conversion.StringValueToPointer(model.Version)),
}, nil
}
func getAllFlavors(ctx context.Context, client sqlserverflexClient, projectId, region string) ([]sqlserverflex.ListFlavors, error) {
if projectId == "" || region == "" {
return nil, fmt.Errorf("listing sqlserverflex flavors: projectId and region are required")
}
var flavorList []sqlserverflex.ListFlavors
page := int64(1)
size := int64(10)
for {
res, err := client.GetFlavorsRequestExecute(ctx, projectId, region, &page, &size, sqlserverflex.FLAVORSORT_INDEX_ASC)
if err != nil {
return nil, fmt.Errorf("listing sqlserverflex flavors: %w", err)
}
if res.Flavors == nil {
return nil, fmt.Errorf("finding flavors for project %s", projectId)
}
pagination := res.GetPagination()
flavorList = append(flavorList, *res.Flavors...)
if *pagination.TotalRows == int64(len(flavorList)) {
break
}
page++
}
return flavorList, nil
}
func loadFlavorId(ctx context.Context, client sqlserverflexClient, model *Model, flavor *flavorModel, storage *storageModel) error {
if model == nil {
return fmt.Errorf("nil model")
}
if flavor == nil {
return fmt.Errorf("nil flavor")
}
cpu := conversion.Int64ValueToPointer(flavor.CPU)
if cpu == nil {
return fmt.Errorf("nil CPU")
}
ram := conversion.Int64ValueToPointer(flavor.RAM)
if ram == nil {
return fmt.Errorf("nil RAM")
}
nodeType := conversion.StringValueToPointer(flavor.NodeType)
if nodeType == nil {
return fmt.Errorf("nil NodeType")
}
storageClass := conversion.StringValueToPointer(storage.Class)
if storageClass == nil {
return fmt.Errorf("nil StorageClass")
}
storageSize := conversion.Int64ValueToPointer(storage.Size)
if storageSize == nil {
return fmt.Errorf("nil StorageSize")
}
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
flavorList, err := getAllFlavors(ctx, client, projectId, region)
if err != nil {
return err
}
avl := ""
foundFlavorCount := 0
for _, f := range flavorList {
if f.Id == nil || f.Cpu == nil || f.Memory == nil {
continue
}
if strings.ToLower(*f.NodeType) != strings.ToLower(*nodeType) {
continue
}
if *f.Cpu == *cpu && *f.Memory == *ram {
var useSc *sqlserverflex.FlavorStorageClassesStorageClass
for _, sc := range *f.StorageClasses {
if *sc.Class != *storageClass {
continue
}
if *storageSize < *f.MinGB || *storageSize > *f.MaxGB {
return fmt.Errorf("storage size %d out of bounds (min: %d - max: %d)", *storageSize, *f.MinGB, *f.MaxGB)
}
useSc = &sc
}
if useSc == nil {
return fmt.Errorf("no storage class found for %s", *storageClass)
}
flavor.Id = types.StringValue(*f.Id)
flavor.Description = types.StringValue(*f.Description)
foundFlavorCount++
}
for _, cls := range *f.StorageClasses {
avl = fmt.Sprintf("%s\n- %d CPU, %d GB RAM, storage %s (min: %d - max: %d)", avl, *f.Cpu, *f.Memory, *cls.Class, *f.MinGB, *f.MaxGB)
}
}
if foundFlavorCount > 1 {
return fmt.Errorf("multiple flavors found: %d flavors", foundFlavorCount)
}
if flavor.Id.ValueString() == "" {
return fmt.Errorf("couldn't find flavor, available specs are:%s", avl)
}
return nil
}
func getFlavorModelById(ctx context.Context, client sqlserverflexClient, model *Model, flavor *flavorModel) error {
if model == nil {
return fmt.Errorf("nil model")
}
if flavor == nil {
return fmt.Errorf("nil flavor")
}
id := conversion.StringValueToPointer(flavor.Id)
if id == nil {
return fmt.Errorf("nil flavor ID")
}
flavor.Id = types.StringValue("")
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
flavorList, err := getAllFlavors(ctx, client, projectId, region)
if err != nil {
return err
}
avl := ""
for _, f := range flavorList {
if f.Id == nil || f.Cpu == nil || f.Memory == nil {
continue
}
if *f.Id == *id {
flavor.Id = types.StringValue(*f.Id)
flavor.Description = types.StringValue(*f.Description)
flavor.CPU = types.Int64Value(*f.Cpu)
flavor.RAM = types.Int64Value(*f.Memory)
flavor.NodeType = types.StringValue(*f.NodeType)
break
}
avl = fmt.Sprintf("%s\n- %d CPU, %d GB RAM", avl, *f.Cpu, *f.Memory)
}
if flavor.Id.ValueString() == "" {
return fmt.Errorf("couldn't find flavor, available specs are: %s", avl)
}
return nil
}

View file

@ -0,0 +1,916 @@
package sqlserverflex
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
sqlserverflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflexalpha/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"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/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"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"
sqlserverflex "github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha/wait"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &instanceResource{}
_ resource.ResourceWithConfigure = &instanceResource{}
_ resource.ResourceWithImportState = &instanceResource{}
_ resource.ResourceWithModifyPlan = &instanceResource{}
)
var validNodeTypes []string = []string{
"Single",
"Replica",
}
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
BackupSchedule types.String `tfsdk:"backup_schedule"`
Flavor types.Object `tfsdk:"flavor"`
Encryption types.Object `tfsdk:"encryption"`
IsDeletable types.Bool `tfsdk:"is_deletable"`
Storage types.Object `tfsdk:"storage"`
Status types.String `tfsdk:"status"`
Version types.String `tfsdk:"version"`
Replicas types.Int64 `tfsdk:"replicas"`
Region types.String `tfsdk:"region"`
Network types.Object `tfsdk:"network"`
Edition types.String `tfsdk:"edition"`
RetentionDays types.Int64 `tfsdk:"retention_days"`
}
type encryptionModel struct {
KeyRingId types.String `tfsdk:"keyring_id"`
KeyId types.String `tfsdk:"key_id"`
KeyVersion types.String `tfsdk:"key_version"`
ServiceAccount types.String `tfsdk:"service_account"`
}
var encryptionTypes = map[string]attr.Type{
"keyring_id": basetypes.StringType{},
"key_id": basetypes.StringType{},
"key_version": basetypes.StringType{},
"service_account": basetypes.StringType{},
}
type networkModel struct {
ACL types.List `tfsdk:"acl"`
AccessScope types.String `tfsdk:"access_scope"`
InstanceAddress types.String `tfsdk:"instance_address"`
RouterAddress types.String `tfsdk:"router_address"`
}
var networkTypes = map[string]attr.Type{
"acl": basetypes.ListType{ElemType: types.StringType},
"access_scope": basetypes.StringType{},
"instance_address": basetypes.StringType{},
"router_address": basetypes.StringType{},
}
// Struct corresponding to Model.FlavorId
type flavorModel struct {
Id types.String `tfsdk:"id"`
Description types.String `tfsdk:"description"`
CPU types.Int64 `tfsdk:"cpu"`
RAM types.Int64 `tfsdk:"ram"`
NodeType types.String `tfsdk:"node_type"`
}
// Types corresponding to flavorModel
var flavorTypes = map[string]attr.Type{
"id": basetypes.StringType{},
"description": basetypes.StringType{},
"cpu": basetypes.Int64Type{},
"ram": basetypes.Int64Type{},
"node_type": basetypes.StringType{},
}
// Struct corresponding to Model.Storage
type storageModel struct {
Class types.String `tfsdk:"class"`
Size types.Int64 `tfsdk:"size"`
}
// Types corresponding to storageModel
var storageTypes = map[string]attr.Type{
"class": basetypes.StringType{},
"size": basetypes.Int64Type{},
}
// NewInstanceResource is a helper function to simplify the provider implementation.
func NewInstanceResource() resource.Resource {
return &instanceResource{}
}
// instanceResource is the resource implementation.
type instanceResource struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_sqlserverflexalpha_instance"
}
// Configure adds the provider configured client to the resource.
func (r *instanceResource) 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 := sqlserverflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "SQLServer Flex instance client configured")
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *instanceResource) 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
}
}
// Schema defines the schema for the resource.
func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "SQLServer Flex ALPHA instance 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`\".",
"instance_id": "ID of the SQLServer Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Instance name.",
"access_scope": "The access scope of the instance. (e.g. SNA)",
"acl": "The Access Control List (ACL) for the SQLServer Flex instance.",
"backup_schedule": `The backup schedule. Should follow the cron scheduling system format (e.g. "0 0 * * *")`,
"region": "The resource region. If not defined, the provider region is used.",
"encryption": "The encryption block.",
"network": "The network block.",
"keyring_id": "STACKIT KMS - KeyRing ID of the encryption key to use.",
"key_id": "STACKIT KMS - Key ID of the encryption key to use.",
"key_version": "STACKIT KMS - Key version to use in the encryption key.",
"service:account": "STACKIT KMS - service account to use in the encryption key.",
"instance_address": "The returned instance IP address of the SQLServer Flex instance.",
"router_address": "The returned router IP address of the SQLServer Flex instance.",
}
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(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.RegexMatches(
regexp.MustCompile("^[a-z]([-a-z0-9]*[a-z0-9])?$"),
"must start with a letter, must have lower case letters, numbers or hyphens, and no hyphen at the end",
),
},
},
"backup_schedule": schema.StringAttribute{
Description: descriptions["backup_schedule"],
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"is_deletable": schema.BoolAttribute{
Description: descriptions["is_deletable"],
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
// TODO - make it either flavor_id or ram, cpu and node_type
"flavor": schema.SingleNestedAttribute{
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplace(),
objectplanmodifier.UseStateForUnknown(),
},
Required: true,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"description": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"node_type": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.ConflictsWith([]path.Expression{
path.MatchRelative().AtParent().AtName("id"),
}...),
stringvalidator.OneOfCaseInsensitive(validNodeTypes...),
stringvalidator.AlsoRequires([]path.Expression{
path.MatchRelative().AtParent().AtName("cpu"),
path.MatchRelative().AtParent().AtName("ram"),
}...),
},
},
"cpu": schema.Int64Attribute{
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
int64planmodifier.UseStateForUnknown(),
},
Validators: []validator.Int64{
int64validator.ConflictsWith([]path.Expression{
path.MatchRelative().AtParent().AtName("id"),
}...),
int64validator.AlsoRequires([]path.Expression{
path.MatchRelative().AtParent().AtName("node_type"),
path.MatchRelative().AtParent().AtName("ram"),
}...),
},
},
"ram": schema.Int64Attribute{
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
int64planmodifier.UseStateForUnknown(),
},
Validators: []validator.Int64{
int64validator.ConflictsWith([]path.Expression{
path.MatchRelative().AtParent().AtName("id"),
}...),
int64validator.AlsoRequires([]path.Expression{
path.MatchRelative().AtParent().AtName("node_type"),
path.MatchRelative().AtParent().AtName("cpu"),
}...),
},
},
},
},
"replicas": schema.Int64Attribute{
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"storage": schema.SingleNestedAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"class": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"size": schema.Int64Attribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
},
},
"version": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"edition": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"retention_days": schema.Int64Attribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"region": schema.StringAttribute{
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
Description: descriptions["region"],
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"status": schema.StringAttribute{
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
Description: descriptions["status"],
},
"encryption": schema.SingleNestedAttribute{
Required: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplace(),
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"key_id": schema.StringAttribute{
Description: descriptions["key_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"key_version": schema.StringAttribute{
Description: descriptions["key_version"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"keyring_id": schema.StringAttribute{
Description: descriptions["key_ring_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"service_account": schema.StringAttribute{
Description: descriptions["service_account"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
},
Description: descriptions["encryption"],
},
"network": schema.SingleNestedAttribute{
Required: true,
Attributes: map[string]schema.Attribute{
"access_scope": schema.StringAttribute{
Description: descriptions["access_scope"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.NoSeparator(),
},
},
"acl": schema.ListAttribute{
Description: descriptions["acl"],
ElementType: types.StringType,
Required: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
},
"instance_address": schema.StringAttribute{
Description: descriptions["instance_address"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"router_address": schema.StringAttribute{
Description: descriptions["router_address"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
Description: descriptions["network"],
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
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()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
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
}
}
var encryption = &encryptionModel{}
if !(model.Encryption.IsNull() || model.Encryption.IsUnknown()) {
diags = model.Encryption.As(ctx, encryption, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var network = &networkModel{}
if !(model.Network.IsNull() || model.Network.IsUnknown()) {
diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
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
}
}
if flavor.Id.IsNull() || flavor.Id.IsUnknown() {
err := loadFlavorId(ctx, r.client, &model, flavor, storage)
if err != nil {
resp.Diagnostics.AddError(err.Error(), err.Error())
return
}
flavorValues := map[string]attr.Value{
"id": flavor.Id,
"description": flavor.Description,
"cpu": flavor.CPU,
"ram": flavor.RAM,
"node_type": flavor.NodeType,
}
var flavorObject basetypes.ObjectValue
flavorObject, diags = types.ObjectValue(flavorTypes, flavorValues)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
model.Flavor = flavorObject
}
// Generate API request body from model
payload, err := toCreatePayload(&model, storage, encryption, network)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new instance
createResp, err := r.client.CreateInstanceRequest(ctx, projectId, region).CreateInstanceRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
instanceId := *createResp.Id
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"instance_id": instanceId,
})
if resp.Diagnostics.HasError() {
return
}
// The creation waiter sometimes returns an error from the API: "instance with id xxx has unexpected status Failure"
// which can be avoided by sleeping before wait
waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, instanceId, region).SetSleepBeforeWait(30 * time.Second).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance creation waiting: %v", err))
return
}
if waitResp.FlavorId == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Instance creation waiting: returned flavor id is nil")
return
}
if *waitResp.FlavorId != flavor.Id.ValueString() {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating instance",
fmt.Sprintf("Instance creation waiting: returned flavor id differs (expected: %s, current: %s)", flavor.Id.ValueString(), *waitResp.FlavorId),
)
return
}
if flavor.CPU.IsNull() || flavor.CPU.IsUnknown() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Instance creation waiting: flavor cpu is null or unknown")
}
if flavor.RAM.IsNull() || flavor.RAM.IsUnknown() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Instance creation waiting: flavor ram is null or unknown")
}
// flavorData := getFlavorModelById(ctx, r.client, &model, flavor)
// Map response body to schema
err = mapFields(ctx, waitResp, &model, flavor, storage, encryption, network, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", 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
}
// After the instance creation, database might not be ready to accept connections immediately.
// That is why we add a sleep
// TODO - can get removed?
time.Sleep(120 * time.Second)
tflog.Info(ctx, "SQLServer Flex instance created")
}
// Read refreshes the Terraform state with the latest data.
func (r *instanceResource) 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()
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)
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
}
err := getFlavorModelById(ctx, r.client, &model, flavor)
if err != nil {
resp.Diagnostics.AddError(err.Error(), err.Error())
return
}
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
}
}
var encryption = &encryptionModel{}
if !(model.Encryption.IsNull() || model.Encryption.IsUnknown()) {
diags = model.Encryption.As(ctx, encryption, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var network = &networkModel{}
if !(model.Network.IsNull() || model.Network.IsUnknown()) {
diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
instanceResp, err := r.client.GetInstanceRequest(ctx, projectId, region, instanceId).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 {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", err.Error())
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, instanceResp, &model, flavor, storage, encryption, network, 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, "SQLServer Flex instance read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
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()
instanceId := model.InstanceId.ValueString()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
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
}
}
var encryption = &encryptionModel{}
if !(model.Encryption.IsNull() || model.Encryption.IsUnknown()) {
diags = model.Encryption.As(ctx, encryption, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var network = &networkModel{}
if !(model.Network.IsNull() || model.Network.IsUnknown()) {
diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
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
}
}
if flavor.Id.IsNull() || flavor.Id.IsUnknown() {
err := loadFlavorId(ctx, r.client, &model, flavor, storage)
if err != nil {
resp.Diagnostics.AddError(err.Error(), err.Error())
return
}
flavorValues := map[string]attr.Value{
"id": flavor.Id,
"description": flavor.Description,
"cpu": flavor.CPU,
"ram": flavor.RAM,
"node_type": flavor.NodeType,
}
var flavorObject basetypes.ObjectValue
flavorObject, diags = types.ObjectValue(flavorTypes, flavorValues)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
model.Flavor = flavorObject
}
// Generate API request body from model
payload, err := toUpdatePayload(&model, storage, network)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing instance
err = r.client.UpdateInstancePartiallyRequest(ctx, projectId, region, instanceId).UpdateInstancePartiallyRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", err.Error())
return
}
ctx = core.LogResponse(ctx)
waitResp, err := wait.UpdateInstanceWaitHandler(ctx, r.client, projectId, instanceId, region).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Instance update waiting: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, waitResp, &model, flavor, storage, encryption, network, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "SQLServer Flex instance updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
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()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
// Delete existing instance
err := r.client.DeleteInstanceRequest(ctx, projectId, region, instanceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteInstanceWaitHandler(ctx, r.client, projectId, instanceId, region).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Instance deletion waiting: %v", err))
return
}
tflog.Info(ctx, "SQLServer Flex instance deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,instance_id
func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing instance",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[instance_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])...)
tflog.Info(ctx, "SQLServer Flex instance state imported")
}

View file

@ -0,0 +1,282 @@
// Copyright (c) STACKIT
package sqlserverflex
import (
"context"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
sqlserverflex "github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
)
func TestNewInstanceResource(t *testing.T) {
tests := []struct {
name string
want resource.Resource
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NewInstanceResource(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewInstanceResource() = %v, want %v", got, tt.want)
}
})
}
}
func Test_instanceResource_Configure(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.ConfigureRequest
resp *resource.ConfigureResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Configure(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_Create(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.CreateRequest
resp *resource.CreateResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Create(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_Delete(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.DeleteRequest
resp *resource.DeleteResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Delete(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_ImportState(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.ImportStateRequest
resp *resource.ImportStateResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.ImportState(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_Metadata(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
in0 context.Context
req resource.MetadataRequest
resp *resource.MetadataResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Metadata(tt.args.in0, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_ModifyPlan(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.ModifyPlanRequest
resp *resource.ModifyPlanResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.ModifyPlan(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_Read(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.ReadRequest
resp *resource.ReadResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Read(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}
func Test_instanceResource_Schema(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
in0 context.Context
in1 resource.SchemaRequest
resp *resource.SchemaResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Schema(tt.args.in0, tt.args.in1, tt.args.resp)
})
}
}
func Test_instanceResource_Update(t *testing.T) {
type fields struct {
client *sqlserverflex.APIClient
providerData core.ProviderData
}
type args struct {
ctx context.Context
req resource.UpdateRequest
resp *resource.UpdateResponse
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &instanceResource{
client: tt.fields.client,
providerData: tt.fields.providerData,
}
r.Update(tt.args.ctx, tt.args.req, tt.args.resp)
})
}
}

View file

@ -0,0 +1,862 @@
package sqlserverflex
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/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
sqlserverflex "github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
)
type sqlserverflexClientMocked struct {
returnError bool
listFlavorsResp *sqlserverflex.GetFlavorsResponse
}
func (c *sqlserverflexClientMocked) GetFlavorsExecute(_ context.Context, _, _ string) (*sqlserverflex.GetFlavorsResponse, error) {
if c.returnError {
return nil, fmt.Errorf("get flavors failed")
}
return c.listFlavorsResp, nil
}
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
state Model
input *sqlserverflex.GetInstanceResponse
flavor *flavorModel
storage *storageModel
encryption *encryptionModel
network *networkModel
region string
expected Model
isValid bool
}{
{
"default_values",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Replicas: types.Int64Value(1),
RetentionDays: types.Int64Value(1),
Version: types.StringValue("v1"),
Edition: types.StringValue("edition 1"),
Status: types.StringValue("status"),
IsDeletable: types.BoolValue(true),
Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{
"id": types.StringValue("flavor_id"),
"description": types.StringNull(),
"cpu": types.Int64Null(),
"ram": types.Int64Null(),
"node_type": types.StringNull(),
}),
},
&sqlserverflex.GetInstanceResponse{
FlavorId: utils.Ptr("flavor_id"),
Replicas: sqlserverflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(1))),
RetentionDays: utils.Ptr(int64(1)),
Version: sqlserverflex.GetInstanceResponseGetVersionAttributeType(utils.Ptr("v1")),
Edition: sqlserverflex.GetInstanceResponseGetEditionAttributeType(utils.Ptr("edition 1")),
Status: sqlserverflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
IsDeletable: utils.Ptr(true),
},
&flavorModel{
Id: types.StringValue("flavor_id"),
},
&storageModel{},
&encryptionModel{},
&networkModel{
ACL: types.ListNull(basetypes.StringType{}),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringNull(),
BackupSchedule: types.StringNull(),
Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{
"id": types.StringValue("flavor_id"),
"description": types.StringNull(),
"cpu": types.Int64Null(),
"ram": types.Int64Null(),
"node_type": types.StringNull(),
}),
Replicas: types.Int64Value(1),
Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{
"class": types.StringNull(),
"size": types.Int64Null(),
}),
Encryption: types.ObjectValueMust(encryptionTypes, map[string]attr.Value{
"keyring_id": types.StringNull(),
"key_id": types.StringNull(),
"key_version": types.StringNull(),
"service_account": types.StringNull(),
}),
Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{
"acl": types.ListNull(types.StringType),
"access_scope": types.StringNull(),
"instance_address": types.StringNull(),
"router_address": types.StringNull(),
}),
IsDeletable: types.BoolValue(true),
Edition: types.StringValue("edition 1"),
Status: types.StringValue("status"),
RetentionDays: types.Int64Value(1),
Version: types.StringValue("v1"),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
Model{
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
},
&sqlserverflex.GetInstanceResponse{
Acl: &[]string{
"ip1",
"ip2",
"",
},
BackupSchedule: utils.Ptr("schedule"),
FlavorId: utils.Ptr("flavor_id"),
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
Replicas: sqlserverflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(56))),
Status: sqlserverflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
Storage: &sqlserverflex.Storage{
Class: utils.Ptr("class"),
Size: utils.Ptr(int64(78)),
},
Edition: sqlserverflex.GetInstanceResponseGetEditionAttributeType(utils.Ptr("edition")),
RetentionDays: utils.Ptr(int64(1)),
Version: sqlserverflex.GetInstanceResponseGetVersionAttributeType(utils.Ptr("version")),
IsDeletable: utils.Ptr(true),
Encryption: nil,
},
&flavorModel{
Id: basetypes.NewStringValue("flavor_id"),
Description: basetypes.NewStringValue("description"),
CPU: basetypes.NewInt64Value(12),
RAM: basetypes.NewInt64Value(34),
NodeType: basetypes.NewStringValue("node_type"),
},
&storageModel{},
&encryptionModel{},
&networkModel{
ACL: types.ListValueMust(basetypes.StringType{}, []attr.Value{
types.StringValue("ip1"),
types.StringValue("ip2"),
types.StringValue(""),
}),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid"),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("name"),
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),
"node_type": types.StringValue("node_type"),
}),
Replicas: types.Int64Value(56),
Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{
"class": types.StringValue("class"),
"size": types.Int64Value(78),
}),
Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{
"acl": types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ip1"),
types.StringValue("ip2"),
types.StringValue(""),
}),
"access_scope": types.StringNull(),
"instance_address": types.StringNull(),
"router_address": types.StringNull(),
}),
Edition: types.StringValue("edition"),
RetentionDays: types.Int64Value(1),
Version: types.StringValue("version"),
Region: types.StringValue(testRegion),
IsDeletable: types.BoolValue(true),
Encryption: types.ObjectValueMust(encryptionTypes, map[string]attr.Value{
"keyring_id": types.StringNull(),
"key_id": types.StringNull(),
"key_version": types.StringNull(),
"service_account": types.StringNull(),
}),
Status: types.StringValue("status"),
},
true,
},
//{
// "simple_values_no_flavor_and_storage",
// Model{
// InstanceId: types.StringValue("iid"),
// ProjectId: types.StringValue("pid"),
// },
// &sqlserverflex.GetInstanceResponse{
// Acl: &[]string{
// "ip1",
// "ip2",
// "",
// },
// BackupSchedule: utils.Ptr("schedule"),
// FlavorId: nil,
// Id: utils.Ptr("iid"),
// Name: utils.Ptr("name"),
// Replicas: sqlserverflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(56))),
// Status: sqlserverflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
// Storage: nil,
// Edition: sqlserverflex.GetInstanceResponseGetEditionAttributeType(utils.Ptr("edition")),
// RetentionDays: utils.Ptr(int64(1)),
// Version: sqlserverflex.GetInstanceResponseGetVersionAttributeType(utils.Ptr("version")),
// },
// &flavorModel{
// CPU: types.Int64Value(12),
// RAM: types.Int64Value(34),
// },
// &storageModel{
// Class: types.StringValue("class"),
// Size: types.Int64Value(78),
// },
// &optionsModel{
// Edition: types.StringValue("edition"),
// RetentionDays: types.Int64Value(1),
// },
// 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),
// }),
// Options: types.ObjectValueMust(optionsTypes, map[string]attr.Value{
// "edition": types.StringValue("edition"),
// "retention_days": types.Int64Value(1),
// }),
// Version: types.StringValue("version"),
// Region: types.StringValue(testRegion),
// },
// true,
//},
//{
// "acls_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"),
// }),
// },
// &sqlserverflex.GetInstanceResponse{
// Acl: &[]string{
// "",
// "ip1",
// "ip2",
// },
// BackupSchedule: utils.Ptr("schedule"),
// FlavorId: nil,
// Id: utils.Ptr("iid"),
// Name: utils.Ptr("name"),
// Replicas: sqlserverflex.GetInstanceResponseGetReplicasAttributeType(utils.Ptr(int32(56))),
// Status: sqlserverflex.GetInstanceResponseGetStatusAttributeType(utils.Ptr("status")),
// Storage: nil,
// //Options: &map[string]string{
// // "edition": "edition",
// // "retentionDays": "1",
// //},
// Version: sqlserverflex.GetInstanceResponseGetVersionAttributeType(utils.Ptr("version")),
// },
// &flavorModel{
// CPU: types.Int64Value(12),
// RAM: types.Int64Value(34),
// },
// &storageModel{
// Class: types.StringValue("class"),
// Size: types.Int64Value(78),
// },
// &optionsModel{},
// 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),
// }),
// Options: types.ObjectValueMust(optionsTypes, map[string]attr.Value{
// "edition": types.StringValue("edition"),
// "retention_days": types.Int64Value(1),
// }),
// Version: types.StringValue("version"),
// Region: types.StringValue(testRegion),
// },
// true,
//},
//{
// "nil_response",
// Model{
// InstanceId: types.StringValue("iid"),
// ProjectId: types.StringValue("pid"),
// },
// nil,
// &flavorModel{},
// &storageModel{},
// &optionsModel{},
// testRegion,
// Model{},
// false,
//},
//{
// "no_resource_id",
// Model{
// InstanceId: types.StringValue("iid"),
// ProjectId: types.StringValue("pid"),
// },
// &sqlserverflex.GetInstanceResponse{},
// &flavorModel{},
// &storageModel{},
// &optionsModel{},
// 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.encryption, tt.network, 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
// inputOptions *optionsModel
// expected *sqlserverflex.CreateInstanceRequestPayload
// isValid bool
// }{
// {
// "default_values",
// &Model{},
// []string{},
// &flavorModel{},
// &storageModel{},
// &optionsModel{},
// &sqlserverflex.CreateInstanceRequestPayload{
// Acl: &sqlserverflex.CreateInstanceRequestPayloadGetAclArgType{},
// Storage: &sqlserverflex.CreateInstanceRequestPayloadGetStorageArgType{},
// },
// 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),
// },
// &optionsModel{
// Edition: types.StringValue("edition"),
// RetentionDays: types.Int64Value(1),
// },
// &sqlserverflex.CreateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{
// "ip_1",
// "ip_2",
// },
// },
// BackupSchedule: utils.Ptr("schedule"),
// FlavorId: utils.Ptr("flavor_id"),
// Name: utils.Ptr("name"),
// Storage: &sqlserverflex.CreateInstancePayloadStorage{
// Class: utils.Ptr("class"),
// Size: utils.Ptr(int64(34)),
// },
// Options: &sqlserverflex.CreateInstancePayloadOptions{
// Edition: utils.Ptr("edition"),
// RetentionDays: utils.Ptr("1"),
// },
// 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(),
// },
// &optionsModel{
// Edition: types.StringNull(),
// RetentionDays: types.Int64Null(),
// },
// &sqlserverflex.CreateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{
// "",
// },
// },
// BackupSchedule: nil,
// FlavorId: nil,
// Name: nil,
// Storage: &sqlserverflex.CreateInstancePayloadStorage{
// Class: nil,
// Size: nil,
// },
// Options: &sqlserverflex.CreateInstancePayloadOptions{},
// Version: nil,
// },
// true,
// },
// {
// "nil_model",
// nil,
// []string{},
// &flavorModel{},
// &storageModel{},
// &optionsModel{},
// nil,
// false,
// },
// {
// "nil_acl",
// &Model{},
// nil,
// &flavorModel{},
// &storageModel{},
// &optionsModel{},
// &sqlserverflex.CreateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{},
// Storage: &sqlserverflex.CreateInstancePayloadStorage{},
// Options: &sqlserverflex.CreateInstancePayloadOptions{},
// },
// true,
// },
// {
// "nil_flavor",
// &Model{},
// []string{},
// nil,
// &storageModel{},
// &optionsModel{},
// nil,
// false,
// },
// {
// "nil_storage",
// &Model{},
// []string{},
// &flavorModel{},
// nil,
// &optionsModel{},
// &sqlserverflex.CreateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{},
// },
// Storage: &sqlserverflex.CreateInstancePayloadStorage{},
// Options: &sqlserverflex.CreateInstancePayloadOptions{},
// },
// true,
// },
// {
// "nil_options",
// &Model{},
// []string{},
// &flavorModel{},
// &storageModel{},
// nil,
// &sqlserverflex.CreateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{},
// },
// Storage: &sqlserverflex.CreateInstancePayloadStorage{},
// Options: &sqlserverflex.CreateInstancePayloadOptions{},
// },
// true,
// },
// }
// for _, tt := range tests {
// t.Run(tt.description, func(t *testing.T) {
// output, err := toCreatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage, tt.inputOptions)
// 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
// expected *sqlserverflex.PartialUpdateInstancePayload
// isValid bool
// }{
// {
// "default_values",
// &Model{},
// []string{},
// &flavorModel{},
// &sqlserverflex.PartialUpdateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{},
// },
// },
// 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"),
// },
// &sqlserverflex.PartialUpdateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{
// "ip_1",
// "ip_2",
// },
// },
// BackupSchedule: utils.Ptr("schedule"),
// FlavorId: utils.Ptr("flavor_id"),
// Name: utils.Ptr("name"),
// 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(),
// },
// &sqlserverflex.PartialUpdateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{
// Items: &[]string{
// "",
// },
// },
// BackupSchedule: nil,
// FlavorId: nil,
// Name: nil,
// Version: nil,
// },
// true,
// },
// {
// "nil_model",
// nil,
// []string{},
// &flavorModel{},
// nil,
// false,
// },
// {
// "nil_acl",
// &Model{},
// nil,
// &flavorModel{},
// &sqlserverflex.PartialUpdateInstancePayload{
// Acl: &sqlserverflex.CreateInstancePayloadAcl{},
// },
// true,
// },
// {
// "nil_flavor",
// &Model{},
// []string{},
// nil,
// nil,
// false,
// },
// }
// for _, tt := range tests {
// t.Run(tt.description, func(t *testing.T) {
// output, err := toUpdatePayload(tt.input, tt.inputAcl, tt.inputFlavor)
// 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 *sqlserverflex.ListFlavorsResponse
// expected *flavorModel
// getFlavorsFails bool
// isValid bool
// }{
// {
// "ok_flavor",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &sqlserverflex.ListFlavorsResponse{
// Flavors: &[]sqlserverflex.InstanceFlavorEntry{
// {
// 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),
// },
// &sqlserverflex.ListFlavorsResponse{
// Flavors: &[]sqlserverflex.InstanceFlavorEntry{
// {
// 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),
// },
// &sqlserverflex.ListFlavorsResponse{
// Flavors: &[]sqlserverflex.InstanceFlavorEntry{
// {
// 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),
// },
// &sqlserverflex.ListFlavorsResponse{},
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// false,
// false,
// },
// {
// "error_response",
// &flavorModel{
// CPU: types.Int64Value(2),
// RAM: types.Int64Value(8),
// },
// &sqlserverflex.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 := &sqlserverflexClientMocked{
// returnError: tt.getFlavorsFails,
// listFlavorsResp: 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

@ -0,0 +1,482 @@
// Copyright (c) STACKIT
package sqlserverflex_test
import (
"context"
_ "embed"
"fmt"
"maps"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
core_config "github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
var (
//go:embed testdata/resource-max.tf
resourceMaxConfig string
//go:embed testdata/resource-min.tf
resourceMinConfig string
)
var testConfigVarsMin = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))),
"flavor_cpu": config.IntegerVariable(4),
"flavor_ram": config.IntegerVariable(16),
"flavor_description": config.StringVariable("SQLServer-Flex-4.16-Standard-EU01"),
"replicas": config.IntegerVariable(1),
"flavor_id": config.StringVariable("4.16-Single"),
"username": config.StringVariable(fmt.Sprintf("tf-acc-user-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlpha))),
"role": config.StringVariable("##STACKIT_LoginManager##"),
}
var testConfigVarsMax = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))),
"acl1": config.StringVariable("192.168.0.0/16"),
"flavor_cpu": config.IntegerVariable(4),
"flavor_ram": config.IntegerVariable(16),
"flavor_description": config.StringVariable("SQLServer-Flex-4.16-Standard-EU01"),
"storage_class": config.StringVariable("premium-perf2-stackit"),
"storage_size": config.IntegerVariable(40),
"server_version": config.StringVariable("2022"),
"replicas": config.IntegerVariable(1),
"options_retention_days": config.IntegerVariable(64),
"flavor_id": config.StringVariable("4.16-Single"),
"backup_schedule": config.StringVariable("00 6 * * *"),
"username": config.StringVariable(fmt.Sprintf("tf-acc-user-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlpha))),
"role": config.StringVariable("##STACKIT_LoginManager##"),
"region": config.StringVariable(testutil.Region),
}
func configVarsMinUpdated() config.Variables {
temp := maps.Clone(testConfigVarsMax)
temp["name"] = config.StringVariable(testutil.ConvertConfigVariable(temp["name"]) + "changed")
return temp
}
func configVarsMaxUpdated() config.Variables {
temp := maps.Clone(testConfigVarsMax)
temp["backup_schedule"] = config.StringVariable("00 12 * * *")
return temp
}
func TestAccSQLServerFlexMinResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccChecksqlserverflexDestroy,
Steps: []resource.TestStep{
// Creation
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMinConfig,
ConfigVariables: testConfigVarsMin,
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigVarsMin["name"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.description", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_description"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "replicas", testutil.ConvertConfigVariable(testConfigVarsMin["replicas"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_cpu"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_ram"])),
// User
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "project_id",
"stackit_sqlserverflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "instance_id",
"stackit_sqlserverflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "user_id"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "password"),
),
},
// Update
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMinConfig,
ConfigVariables: testConfigVarsMin,
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigVarsMin["name"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.description", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_description"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_cpu"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_ram"])),
// User
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "project_id",
"stackit_sqlserverflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "instance_id",
"stackit_sqlserverflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "user_id"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "password"),
),
},
// data source
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMinConfig,
ConfigVariables: testConfigVarsMin,
Check: resource.ComposeAggregateTestCheckFunc(
// Instance data
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigVarsMin["name"])),
resource.TestCheckResourceAttrPair(
"data.stackit_sqlserverflex_instance.instance", "project_id",
"stackit_sqlserverflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_sqlserverflex_instance.instance", "instance_id",
"stackit_sqlserverflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_sqlserverflex_user.user", "instance_id",
"stackit_sqlserverflex_user.user", "instance_id",
),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.id", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_id"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.description", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_description"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_cpu"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(testConfigVarsMin["flavor_ram"])),
// User data
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])),
resource.TestCheckResourceAttrSet("data.stackit_sqlserverflex_user.user", "user_id"),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "username", testutil.ConvertConfigVariable(testConfigVarsMin["username"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "roles.#", "1"),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "roles.0", testutil.ConvertConfigVariable(testConfigVarsMax["role"])),
),
},
// Import
{
ConfigVariables: testConfigVarsMin,
ResourceName: "stackit_sqlserverflex_instance.instance",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_sqlserverflex_instance.instance"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_sqlserverflex_instance.instance")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"backup_schedule"},
ImportStateCheck: func(s []*terraform.InstanceState) error {
if len(s) != 1 {
return fmt.Errorf("expected 1 state, got %d", len(s))
}
return nil
},
},
{
ResourceName: "stackit_sqlserverflex_user.user",
ConfigVariables: testConfigVarsMin,
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_sqlserverflex_user.user"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_sqlserverflex_user.user")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
userId, ok := r.Primary.Attributes["user_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute user_id")
}
return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId, userId), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"password"},
},
// Update
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMinConfig,
ConfigVariables: configVarsMinUpdated(),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance data
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(configVarsMinUpdated()["project_id"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(configVarsMinUpdated()["name"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.description"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(configVarsMinUpdated()["flavor_cpu"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(configVarsMinUpdated()["flavor_ram"])),
),
},
// Deletion is done by the framework implicitly
},
})
}
func TestAccSQLServerFlexMaxResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccChecksqlserverflexDestroy,
Steps: []resource.TestStep{
// Creation
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMaxConfig,
ConfigVariables: testConfigVarsMax,
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl1"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.description", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_description"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "replicas", testutil.ConvertConfigVariable(testConfigVarsMax["replicas"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_cpu"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_ram"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigVarsMax["storage_class"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigVarsMax["storage_size"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigVarsMax["server_version"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "options.retention_days", testutil.ConvertConfigVariable(testConfigVarsMax["options_retention_days"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigVarsMax["backup_schedule"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "region", testutil.Region),
// User
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "project_id",
"stackit_sqlserverflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "instance_id",
"stackit_sqlserverflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "user_id"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "password"),
),
},
// Update
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMaxConfig,
ConfigVariables: testConfigVarsMax,
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl1"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.description", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_description"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "replicas", testutil.ConvertConfigVariable(testConfigVarsMax["replicas"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_cpu"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_ram"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigVarsMax["storage_class"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigVarsMax["storage_size"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigVarsMax["server_version"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "options.retention_days", testutil.ConvertConfigVariable(testConfigVarsMax["options_retention_days"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigVarsMax["backup_schedule"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "region", testutil.Region),
// User
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "project_id",
"stackit_sqlserverflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_sqlserverflex_user.user", "instance_id",
"stackit_sqlserverflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "user_id"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_user.user", "password"),
),
},
// data source
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMaxConfig,
ConfigVariables: testConfigVarsMax,
Check: resource.ComposeAggregateTestCheckFunc(
// Instance data
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])),
resource.TestCheckResourceAttrPair(
"data.stackit_sqlserverflex_instance.instance", "project_id",
"stackit_sqlserverflex_instance.instance", "project_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_sqlserverflex_instance.instance", "instance_id",
"stackit_sqlserverflex_instance.instance", "instance_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_sqlserverflex_user.user", "instance_id",
"stackit_sqlserverflex_user.user", "instance_id",
),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl1"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.id", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_id"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.description", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_description"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_cpu"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(testConfigVarsMax["flavor_ram"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "replicas", testutil.ConvertConfigVariable(testConfigVarsMax["replicas"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "options.retention_days", testutil.ConvertConfigVariable(testConfigVarsMax["options_retention_days"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigVarsMax["backup_schedule"])),
// User data
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])),
resource.TestCheckResourceAttrSet("data.stackit_sqlserverflex_user.user", "user_id"),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "username", testutil.ConvertConfigVariable(testConfigVarsMax["username"])),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "roles.#", "1"),
resource.TestCheckResourceAttr("data.stackit_sqlserverflex_user.user", "roles.0", testutil.ConvertConfigVariable(testConfigVarsMax["role"])),
resource.TestCheckResourceAttrSet("data.stackit_sqlserverflex_user.user", "host"),
resource.TestCheckResourceAttrSet("data.stackit_sqlserverflex_user.user", "port"),
),
},
// Import
{
ConfigVariables: testConfigVarsMax,
ResourceName: "stackit_sqlserverflex_instance.instance",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_sqlserverflex_instance.instance"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_sqlserverflex_instance.instance")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"backup_schedule"},
ImportStateCheck: func(s []*terraform.InstanceState) error {
if len(s) != 1 {
return fmt.Errorf("expected 1 state, got %d", len(s))
}
if s[0].Attributes["backup_schedule"] != testutil.ConvertConfigVariable(testConfigVarsMax["backup_schedule"]) {
return fmt.Errorf("expected backup_schedule %s, got %s", testConfigVarsMax["backup_schedule"], s[0].Attributes["backup_schedule"])
}
return nil
},
},
{
ResourceName: "stackit_sqlserverflex_user.user",
ConfigVariables: testConfigVarsMax,
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_sqlserverflex_user.user"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_sqlserverflex_user.user")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
userId, ok := r.Primary.Attributes["user_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute user_id")
}
return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId, userId), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"password"},
},
// Update
{
Config: testutil.SQLServerFlexProviderConfig() + "\n" + resourceMaxConfig,
ConfigVariables: configVarsMaxUpdated(),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance data
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "project_id", testutil.ConvertConfigVariable(configVarsMaxUpdated()["project_id"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "instance_id"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "name", testutil.ConvertConfigVariable(configVarsMaxUpdated()["name"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.#", "1"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(configVarsMaxUpdated()["acl1"])),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.id"),
resource.TestCheckResourceAttrSet("stackit_sqlserverflex_instance.instance", "flavor.description"),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.cpu", testutil.ConvertConfigVariable(configVarsMaxUpdated()["flavor_cpu"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "flavor.ram", testutil.ConvertConfigVariable(configVarsMaxUpdated()["flavor_ram"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "replicas", testutil.ConvertConfigVariable(configVarsMaxUpdated()["replicas"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(configVarsMaxUpdated()["storage_class"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(configVarsMaxUpdated()["storage_size"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "version", testutil.ConvertConfigVariable(configVarsMaxUpdated()["server_version"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "options.retention_days", testutil.ConvertConfigVariable(configVarsMaxUpdated()["options_retention_days"])),
resource.TestCheckResourceAttr("stackit_sqlserverflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(configVarsMaxUpdated()["backup_schedule"])),
),
},
// Deletion is done by the framework implicitly
},
})
}
func testAccChecksqlserverflexDestroy(s *terraform.State) error {
ctx := context.Background()
var client *sqlserverflex.APIClient
var err error
if testutil.SQLServerFlexCustomEndpoint == "" {
client, err = sqlserverflex.NewAPIClient()
} else {
client, err = sqlserverflex.NewAPIClient(
core_config.WithEndpoint(testutil.SQLServerFlexCustomEndpoint),
)
}
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
instancesToDestroy := []string{}
for _, rs := range s.RootModule().Resources {
if rs.Type != "stackit_sqlserverflex_instance" {
continue
}
// instance terraform ID: = "[project_id],[region],[instance_id]"
instanceId := strings.Split(rs.Primary.ID, core.Separator)[2]
instancesToDestroy = append(instancesToDestroy, instanceId)
}
instancesResp, err := client.ListInstances(ctx, testutil.ProjectId, testutil.Region).Execute()
if err != nil {
return fmt.Errorf("getting instancesResp: %w", err)
}
items := *instancesResp.Items
for i := range items {
if items[i].Id == nil {
continue
}
if utils.Contains(instancesToDestroy, *items[i].Id) {
err := client.DeleteInstanceExecute(ctx, testutil.ProjectId, *items[i].Id, testutil.Region)
if err != nil {
return fmt.Errorf("destroying instance %s during CheckDestroy: %w", *items[i].Id, err)
}
_, err = wait.DeleteInstanceWaitHandler(ctx, client, testutil.ProjectId, *items[i].Id, testutil.Region).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("destroying instance %s during CheckDestroy: waiting for deletion %w", *items[i].Id, err)
}
}
}
return nil
}

View file

@ -0,0 +1,53 @@
# Copyright (c) STACKIT
variable "project_id" {}
variable "name" {}
variable "acl1" {}
variable "flavor_cpu" {}
variable "flavor_ram" {}
variable "storage_class" {}
variable "storage_size" {}
variable "options_retention_days" {}
variable "backup_schedule" {}
variable "username" {}
variable "role" {}
variable "server_version" {}
variable "region" {}
resource "stackit_sqlserverflex_instance" "instance" {
project_id = var.project_id
name = var.name
acl = [var.acl1]
flavor = {
cpu = var.flavor_cpu
ram = var.flavor_ram
}
storage = {
class = var.storage_class
size = var.storage_size
}
version = var.server_version
options = {
retention_days = var.options_retention_days
}
backup_schedule = var.backup_schedule
region = var.region
}
resource "stackit_sqlserverflex_user" "user" {
project_id = stackit_sqlserverflex_instance.instance.project_id
instance_id = stackit_sqlserverflex_instance.instance.instance_id
username = var.username
roles = [var.role]
}
data "stackit_sqlserverflex_instance" "instance" {
project_id = var.project_id
instance_id = stackit_sqlserverflex_instance.instance.instance_id
}
data "stackit_sqlserverflex_user" "user" {
project_id = var.project_id
instance_id = stackit_sqlserverflex_instance.instance.instance_id
user_id = stackit_sqlserverflex_user.user.user_id
}

View file

@ -0,0 +1,35 @@
# Copyright (c) STACKIT
variable "project_id" {}
variable "name" {}
variable "flavor_cpu" {}
variable "flavor_ram" {}
variable "username" {}
variable "role" {}
resource "stackit_sqlserverflex_instance" "instance" {
project_id = var.project_id
name = var.name
flavor = {
cpu = var.flavor_cpu
ram = var.flavor_ram
}
}
resource "stackit_sqlserverflex_user" "user" {
project_id = stackit_sqlserverflex_instance.instance.project_id
instance_id = stackit_sqlserverflex_instance.instance.instance_id
username = var.username
roles = [var.role]
}
data "stackit_sqlserverflex_instance" "instance" {
project_id = var.project_id
instance_id = stackit_sqlserverflex_instance.instance.instance_id
}
data "stackit_sqlserverflex_user" "user" {
project_id = var.project_id
instance_id = stackit_sqlserverflex_instance.instance.instance_id
user_id = stackit_sqlserverflex_user.user.user_id
}

View file

@ -0,0 +1,274 @@
// Copyright (c) STACKIT
package sqlserverflexalpha
import (
"context"
"fmt"
"net/http"
"strconv"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
sqlserverflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflexalpha/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/hashicorp/terraform-plugin-framework/types"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &userDataSource{}
)
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
UserId types.Int64 `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"`
Status types.String `tfsdk:"status"`
DefaultDatabase types.String `tfsdk:"default_database"`
}
// NewUserDataSource is a helper function to simplify the provider implementation.
func NewUserDataSource() datasource.DataSource {
return &userDataSource{}
}
// userDataSource is the data source implementation.
type userDataSource struct {
client *sqlserverflexalpha.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 + "_sqlserverflexalpha_user"
}
// Configure adds the provider configured client to the data source.
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 {
return
}
apiClient := sqlserverflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "SQLServer Flex user client configured")
}
// Schema defines the schema for the data source.
func (r *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
descriptions := map[string]string{
"main": "SQLServer 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 SQLServer Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"username": "Username of the SQLServer Flex instance.",
"roles": "Database access levels for the user.",
"password": "Password of the user account.",
"region": "The resource region. If not defined, the provider region is used.",
"status": "Status of the user.",
"default_database": "Default database of the user.",
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"user_id": schema.Int64Attribute{
Description: descriptions["user_id"],
Required: true,
Validators: []validator.Int64{
int64validator.AtLeast(1),
},
},
"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{
Description: descriptions["username"],
Computed: true,
},
"roles": schema.SetAttribute{
Description: descriptions["roles"],
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"],
},
"status": schema.StringAttribute{
Computed: true,
},
"default_database": schema.StringAttribute{
Computed: true,
},
},
}
}
// 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
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()
userId := model.UserId.ValueInt64()
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)
recordSetResp, err := r.client.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),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// 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),
)
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "SQLServer Flex instance read")
}
func mapDataSourceFields(userResp *sqlserverflexalpha.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 {
userId = model.UserId.ValueInt64()
} 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(), strconv.FormatInt(userId, 10),
)
model.UserId = types.Int64Value(userId)
model.Username = types.StringPointerValue(user.Username)
if user.Roles == nil {
model.Roles = 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 = rolesSet
}
model.Host = types.StringPointerValue(user.Host)
model.Port = types.Int64PointerValue(user.Port)
model.Region = types.StringValue(region)
model.Status = types.StringPointerValue(user.Status)
model.DefaultDatabase = types.StringPointerValue(user.DefaultDatabase)
return nil
}

View file

@ -0,0 +1,149 @@
// Copyright (c) STACKIT
package sqlserverflexalpha
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/terraform-provider-stackit/pkg/sqlserverflexalpha"
)
func TestMapDataSourceFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *sqlserverflexalpha.GetUserResponse
region string
expected DataSourceModel
isValid bool
}{
{
"default_values",
&sqlserverflexalpha.GetUserResponse{},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,iid,1"),
UserId: types.Int64Value(1),
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),
Status: types.StringNull(),
DefaultDatabase: types.StringNull(),
},
true,
},
{
"simple_values",
&sqlserverflexalpha.GetUserResponse{
Roles: &[]sqlserverflexalpha.UserRole{
"role_1",
"role_2",
"",
},
Username: utils.Ptr("username"),
Host: utils.Ptr("host"),
Port: utils.Ptr(int64(1234)),
Status: utils.Ptr("active"),
DefaultDatabase: utils.Ptr("default_db"),
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,iid,1"),
UserId: types.Int64Value(1),
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),
Status: types.StringValue("active"),
DefaultDatabase: types.StringValue("default_db"),
},
true,
},
{
"null_fields_and_int_conversions",
&sqlserverflexalpha.GetUserResponse{
Id: utils.Ptr(int64(1)),
Roles: &[]sqlserverflexalpha.UserRole{},
Username: nil,
Host: nil,
Port: utils.Ptr(int64(2123456789)),
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,iid,1"),
UserId: types.Int64Value(1),
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",
&sqlserverflexalpha.GetUserResponse{},
testRegion,
DataSourceModel{},
false,
},
{
"no_resource_id",
&sqlserverflexalpha.GetUserResponse{},
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,568 @@
// Copyright (c) STACKIT
package sqlserverflexalpha
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
sqlserverflexalphaUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflexalpha/utils"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"github.com/hashicorp/terraform-plugin-framework/attr"
"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/setplanmodifier"
"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 = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
UserId types.Int64 `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"`
Password types.String `tfsdk:"password"`
Host types.String `tfsdk:"host"`
Port types.Int64 `tfsdk:"port"`
Region types.String `tfsdk:"region"`
Status types.String `tfsdk:"status"`
DefaultDatabase types.String `tfsdk:"default_database"`
}
// NewUserResource is a helper function to simplify the provider implementation.
func NewUserResource() resource.Resource {
return &userResource{}
}
// userResource is the resource implementation.
type userResource struct {
client *sqlserverflexalpha.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_sqlserverflexalpha_user"
}
// Configure adds the provider configured client to the resource.
func (r *userResource) 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 := sqlserverflexalphaUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "SQLServer Alpha Flex user client configured")
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *userResource) 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
}
}
// Schema defines the schema for the resource.
func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "SQLServer Flex user 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`,`user_id`\".",
"user_id": "User ID.",
"instance_id": "ID of the SQLServer Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"username": "Username of the SQLServer Flex instance.",
"roles": "Database access levels for the user. The values for the default roles are: `##STACKIT_DatabaseManager##`, `##STACKIT_LoginManager##`, `##STACKIT_ProcessManager##`, `##STACKIT_ServerManager##`, `##STACKIT_SQLAgentManager##`, `##STACKIT_SQLAgentUser##`",
"password": "Password of the user account.",
"status": "Status of the user.",
"default_database": "Default database of the user.",
}
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(),
},
},
"user_id": schema.StringAttribute{
Description: descriptions["user_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(),
},
},
"username": schema.StringAttribute{
Description: descriptions["username"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"roles": schema.SetAttribute{
Description: descriptions["roles"],
ElementType: types.StringType,
Required: true,
PlanModifiers: []planmodifier.Set{
setplanmodifier.RequiresReplace(),
},
},
"password": schema.StringAttribute{
Description: descriptions["password"],
Computed: true,
Sensitive: true,
},
"host": schema.StringAttribute{
Computed: true,
},
"port": schema.Int64Attribute{
Computed: true,
},
"region": schema.StringAttribute{
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
Description: descriptions["region"],
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"status": schema.StringAttribute{
Computed: true,
},
"default_database": schema.StringAttribute{
Computed: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *userResource) 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()
instanceId := model.InstanceId.ValueString()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
var roles []sqlserverflexalpha.UserRole
if !(model.Roles.IsNull() || model.Roles.IsUnknown()) {
diags = model.Roles.ElementsAs(ctx, &roles, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(&model, roles)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new user
userResp, err := r.client.CreateUserRequest(
ctx,
projectId,
region,
instanceId,
).CreateUserRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if userResp == nil || userResp.Id == nil || *userResp.Id == 0 {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating user",
"API didn't return user Id. A user might have been created",
)
return
}
userId := *userResp.Id
ctx = tflog.SetField(ctx, "user_id", userId)
// Map response body to schema
err = mapFieldsCreate(userResp, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating user",
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, "SQLServer Flex user created")
}
// Read refreshes the Terraform state with the latest data.
func (r *userResource) 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()
userId := model.UserId.ValueInt64()
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)
recordSetResp, err := r.client.GetUserRequest(ctx, projectId, region, instanceId, userId).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
ok := errors.As(
err,
&oapiErr,
)
//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 {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(recordSetResp, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading user",
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, "SQLServer Flex user read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *userResource) 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 user", "User can't be updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *userResource) 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()
userId := model.UserId.ValueInt64()
region := model.Region.ValueString()
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)
// Delete existing record set
err := r.client.DeleteUserRequest(ctx, projectId, region, instanceId, userId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting user", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "SQLServer Flex user 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 *userResource) 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 user",
fmt.Sprintf(
"Expected import identifier with format [project_id],[region],[instance_id],[user_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("user_id"), idParts[3])...)
core.LogAndAddWarning(
ctx,
&resp.Diagnostics,
"SQLServer Flex user imported with empty password",
"The user password is not imported as it is only available upon creation of a new user. The password field will be empty.",
)
tflog.Info(ctx, "SQLServer Flex user state imported")
}
func mapFieldsCreate(userResp *sqlserverflexalpha.CreateUserResponse, model *Model, region string) error {
if userResp == nil {
return fmt.Errorf("response is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
user := userResp
if user.Id == nil {
return fmt.Errorf("user id not present")
}
userId := *user.Id
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(),
region,
model.InstanceId.ValueString(),
strconv.FormatInt(userId, 10),
)
model.UserId = types.Int64Value(userId)
model.Username = types.StringPointerValue(user.Username)
if user.Password == nil {
return fmt.Errorf("user password not present")
}
model.Password = types.StringValue(*user.Password)
if user.Roles != nil {
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 = rolesSet
}
if model.Roles.IsNull() || model.Roles.IsUnknown() {
model.Roles = types.SetNull(types.StringType)
}
model.Host = types.StringPointerValue(user.Host)
model.Port = types.Int64PointerValue(user.Port)
model.Region = types.StringValue(region)
model.Status = types.StringPointerValue(user.Status)
model.DefaultDatabase = types.StringPointerValue(user.DefaultDatabase)
return nil
}
func mapFields(userResp *sqlserverflexalpha.GetUserResponse, model *Model, 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 {
userId = model.UserId.ValueInt64()
} 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(),
strconv.FormatInt(userId, 10),
)
model.UserId = types.Int64Value(userId)
model.Username = types.StringPointerValue(user.Username)
if user.Roles != nil {
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 = rolesSet
}
if model.Roles.IsNull() || model.Roles.IsUnknown() {
model.Roles = types.SetNull(types.StringType)
}
model.Host = types.StringPointerValue(user.Host)
model.Port = types.Int64PointerValue(user.Port)
model.Region = types.StringValue(region)
return nil
}
func toCreatePayload(
model *Model,
roles []sqlserverflexalpha.UserRole,
) (*sqlserverflexalpha.CreateUserRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &sqlserverflexalpha.CreateUserRequestPayload{
Username: conversion.StringValueToPointer(model.Username),
DefaultDatabase: conversion.StringValueToPointer(model.DefaultDatabase),
Roles: &roles,
}, nil
}

View file

@ -0,0 +1,387 @@
// Copyright (c) STACKIT
package sqlserverflexalpha
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/terraform-provider-stackit/pkg/sqlserverflexalpha"
)
func TestMapFieldsCreate(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *sqlserverflexalpha.CreateUserResponse
region string
expected Model
isValid bool
}{
{
"default_values",
&sqlserverflexalpha.CreateUserResponse{
Id: utils.Ptr(int64(1)),
Password: utils.Ptr(""),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,1"),
UserId: types.Int64Value(1),
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(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
&sqlserverflexalpha.CreateUserResponse{
Id: utils.Ptr(int64(2)),
Roles: &[]sqlserverflexalpha.UserRole{
"role_1",
"role_2",
"",
},
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
Host: utils.Ptr("host"),
Port: utils.Ptr(int64(1234)),
Status: utils.Ptr("status"),
DefaultDatabase: utils.Ptr("default_db"),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,2"),
UserId: types.Int64Value(2),
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),
Region: types.StringValue(testRegion),
Status: types.StringValue("status"),
DefaultDatabase: types.StringValue("default_db"),
},
true,
},
{
"null_fields_and_int_conversions",
&sqlserverflexalpha.CreateUserResponse{
Id: utils.Ptr(int64(3)),
Roles: &[]sqlserverflexalpha.UserRole{},
Username: nil,
Password: utils.Ptr(""),
Host: nil,
Port: utils.Ptr(int64(2123456789)),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,3"),
UserId: types.Int64Value(3),
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),
Region: types.StringValue(testRegion),
DefaultDatabase: types.StringNull(),
Status: types.StringNull(),
},
true,
},
{
"nil_response",
nil,
testRegion,
Model{},
false,
},
{
"nil_response_2",
&sqlserverflexalpha.CreateUserResponse{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
&sqlserverflexalpha.CreateUserResponse{},
testRegion,
Model{},
false,
},
{
"no_password",
&sqlserverflexalpha.CreateUserResponse{
Id: utils.Ptr(int64(1)),
},
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 *sqlserverflexalpha.GetUserResponse
region string
expected Model
isValid bool
}{
{
"default_values",
&sqlserverflexalpha.GetUserResponse{},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,1"),
UserId: types.Int64Value(1),
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",
&sqlserverflexalpha.GetUserResponse{
Roles: &[]sqlserverflexalpha.UserRole{
"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,2"),
UserId: types.Int64Value(2),
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",
&sqlserverflexalpha.GetUserResponse{
Id: utils.Ptr(int64(1)),
Roles: &[]sqlserverflexalpha.UserRole{},
Username: nil,
Host: nil,
Port: utils.Ptr(int64(2123456789)),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,1"),
UserId: types.Int64Value(1),
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",
&sqlserverflexalpha.GetUserResponse{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
&sqlserverflexalpha.GetUserResponse{},
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 []sqlserverflexalpha.UserRole
expected *sqlserverflexalpha.CreateUserRequestPayload
isValid bool
}{
{
"default_values",
&Model{},
[]sqlserverflexalpha.UserRole{},
&sqlserverflexalpha.CreateUserRequestPayload{
Roles: &[]sqlserverflexalpha.UserRole{},
Username: nil,
},
true,
},
{
"default_values",
&Model{
Username: types.StringValue("username"),
},
[]sqlserverflexalpha.UserRole{
"role_1",
"role_2",
},
&sqlserverflexalpha.CreateUserRequestPayload{
Roles: &[]sqlserverflexalpha.UserRole{
"role_1",
"role_2",
},
Username: utils.Ptr("username"),
},
true,
},
{
"null_fields_and_int_conversions",
&Model{
Username: types.StringNull(),
},
[]sqlserverflexalpha.UserRole{
"",
},
&sqlserverflexalpha.CreateUserRequestPayload{
Roles: &[]sqlserverflexalpha.UserRole{
"",
},
Username: nil,
},
true,
},
{
"nil_model",
nil,
[]sqlserverflexalpha.UserRole{},
nil,
false,
},
{
"nil_roles",
&Model{
Username: types.StringValue("username"),
},
[]sqlserverflexalpha.UserRole{},
&sqlserverflexalpha.CreateUserRequestPayload{
Roles: &[]sqlserverflexalpha.UserRole{},
Username: utils.Ptr("username"),
},
true,
},
}
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)
}
}
},
)
}
}

View file

@ -0,0 +1,49 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"fmt"
sqlserverflex "github.com/stackitcloud/terraform-provider-stackit/pkg/sqlserverflexalpha"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
func ConfigureClient(
ctx context.Context,
providerData *core.ProviderData,
diags *diag.Diagnostics,
) *sqlserverflex.APIClient {
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(providerData.RoundTripper),
utils.UserAgentConfigOption(providerData.Version),
}
if providerData.SQLServerFlexCustomEndpoint != "" {
apiClientConfigOptions = append(
apiClientConfigOptions,
config.WithEndpoint(providerData.SQLServerFlexCustomEndpoint),
)
} else {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithRegion(providerData.GetRegion()))
}
apiClient, err := sqlserverflex.NewAPIClient(apiClientConfigOptions...)
if err != nil {
core.LogAndAddError(
ctx,
diags,
"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 nil
}
return apiClient
}

View file

@ -0,0 +1,99 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"os"
"reflect"
"testing"
"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/terraform-provider-stackit/pkg/sqlserverflexalpha"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
const (
testVersion = "1.2.3"
testCustomEndpoint = "https://sqlserverflex-custom-endpoint.api.stackit.cloud"
)
func TestConfigureClient(t *testing.T) {
/* mock authentication by setting service account token env variable */
os.Clearenv()
err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val")
if err != nil {
t.Errorf("error setting env variable: %v", err)
}
type args struct {
providerData *core.ProviderData
}
tests := []struct {
name string
args args
wantErr bool
expected *sqlserverflexalpha.APIClient
}{
{
name: "default endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
},
},
expected: func() *sqlserverflexalpha.APIClient {
apiClient, err := sqlserverflexalpha.NewAPIClient(
config.WithRegion("eu01"),
utils.UserAgentConfigOption(testVersion),
)
if err != nil {
t.Errorf("error configuring client: %v", err)
}
return apiClient
}(),
wantErr: false,
},
{
name: "custom endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
SQLServerFlexCustomEndpoint: testCustomEndpoint,
},
},
expected: func() *sqlserverflexalpha.APIClient {
apiClient, err := sqlserverflexalpha.NewAPIClient(
utils.UserAgentConfigOption(testVersion),
config.WithEndpoint(testCustomEndpoint),
)
if err != nil {
t.Errorf("error configuring client: %v", err)
}
return apiClient
}(),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
ctx := context.Background()
diags := diag.Diagnostics{}
actual := ConfigureClient(ctx, tt.args.providerData, &diags)
if diags.HasError() != tt.wantErr {
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
}
if !reflect.DeepEqual(actual, tt.expected) {
t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected)
}
},
)
}
}

View file

@ -0,0 +1,610 @@
// Copyright (c) STACKIT
package testutil
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/echoprovider"
"github.com/stackitcloud/terraform-provider-stackit/stackit"
)
const (
// Default location of credentials JSON
credentialsFilePath = ".stackit/credentials.json" //nolint:gosec // linter false positive
)
var (
// TestAccProtoV6ProviderFactories is used to instantiate a provider during
// acceptance testing. The factory function will be invoked for every Terraform
// CLI command executed to create a provider server to which the CLI can
// reattach.
TestAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"stackit": providerserver.NewProtocol6WithError(stackit.New("test-version")()),
}
// TestEphemeralAccProtoV6ProviderFactories is used to instantiate a provider during
// acceptance testing. The factory function will be invoked for every Terraform
// CLI command executed to create a provider server to which the CLI can
// reattach.
//
// See the Terraform acceptance test documentation on ephemeral resources for more information:
// https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/ephemeral-resources
TestEphemeralAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"stackit": providerserver.NewProtocol6WithError(stackit.New("test-version")()),
"echo": echoprovider.NewProviderServer(),
}
// E2ETestsEnabled checks if end-to-end tests should be run.
// It is enabled when the TF_ACC environment variable is set to "1".
E2ETestsEnabled = os.Getenv("TF_ACC") == "1"
// OrganizationId is the id of organization used for tests
OrganizationId = os.Getenv("TF_ACC_ORGANIZATION_ID")
// ProjectId is the id of project used for tests
ProjectId = os.Getenv("TF_ACC_PROJECT_ID")
Region = os.Getenv("TF_ACC_REGION")
// ServerId is the id of a server used for some tests
ServerId = getenv("TF_ACC_SERVER_ID", "")
// TestProjectParentContainerID is the container id of the parent resource under which projects are created as part of the resource-manager acceptance tests
TestProjectParentContainerID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_CONTAINER_ID")
// TestProjectParentUUID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests
TestProjectParentUUID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_UUID")
// TestProjectServiceAccountEmail is the e-mail of a service account with admin permissions on the organization under which projects are created as part of the resource-manager acceptance tests
TestProjectServiceAccountEmail = os.Getenv("TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_EMAIL")
// TestProjectUserEmail is the e-mail of a user for the project created as part of the resource-manager acceptance tests
// Default email: acc-test@sa.stackit.cloud
TestProjectUserEmail = getenv("TF_ACC_TEST_PROJECT_USER_EMAIL", "acc-test@sa.stackit.cloud")
// TestImageLocalFilePath is the local path to an image file used for image acceptance tests
TestImageLocalFilePath = getenv("TF_ACC_TEST_IMAGE_LOCAL_FILE_PATH", "default")
CdnCustomEndpoint = os.Getenv("TF_ACC_CDN_CUSTOM_ENDPOINT")
DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT")
GitCustomEndpoint = os.Getenv("TF_ACC_GIT_CUSTOM_ENDPOINT")
IaaSCustomEndpoint = os.Getenv("TF_ACC_IAAS_CUSTOM_ENDPOINT")
KMSCustomEndpoint = os.Getenv("TF_ACC_KMS_CUSTOM_ENDPOINT")
LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT")
LogMeCustomEndpoint = os.Getenv("TF_ACC_LOGME_CUSTOM_ENDPOINT")
MariaDBCustomEndpoint = os.Getenv("TF_ACC_MARIADB_CUSTOM_ENDPOINT")
ModelServingCustomEndpoint = os.Getenv("TF_ACC_MODELSERVING_CUSTOM_ENDPOINT")
AuthorizationCustomEndpoint = os.Getenv("TF_ACC_authorization_custom_endpoint")
MongoDBFlexCustomEndpoint = os.Getenv("TF_ACC_MONGODBFLEX_CUSTOM_ENDPOINT")
OpenSearchCustomEndpoint = os.Getenv("TF_ACC_OPENSEARCH_CUSTOM_ENDPOINT")
ObservabilityCustomEndpoint = os.Getenv("TF_ACC_OBSERVABILITY_CUSTOM_ENDPOINT")
ObjectStorageCustomEndpoint = os.Getenv("TF_ACC_OBJECTSTORAGE_CUSTOM_ENDPOINT")
PostgresFlexCustomEndpoint = os.Getenv("TF_ACC_POSTGRESFLEX_CUSTOM_ENDPOINT")
RabbitMQCustomEndpoint = os.Getenv("TF_ACC_RABBITMQ_CUSTOM_ENDPOINT")
RedisCustomEndpoint = os.Getenv("TF_ACC_REDIS_CUSTOM_ENDPOINT")
ResourceManagerCustomEndpoint = os.Getenv("TF_ACC_RESOURCEMANAGER_CUSTOM_ENDPOINT")
ScfCustomEndpoint = os.Getenv("TF_ACC_SCF_CUSTOM_ENDPOINT")
SecretsManagerCustomEndpoint = os.Getenv("TF_ACC_SECRETSMANAGER_CUSTOM_ENDPOINT")
SQLServerFlexCustomEndpoint = os.Getenv("TF_ACC_SQLSERVERFLEX_CUSTOM_ENDPOINT")
ServerBackupCustomEndpoint = os.Getenv("TF_ACC_SERVER_BACKUP_CUSTOM_ENDPOINT")
ServerUpdateCustomEndpoint = os.Getenv("TF_ACC_SERVER_UPDATE_CUSTOM_ENDPOINT")
ServiceAccountCustomEndpoint = os.Getenv("TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT")
SKECustomEndpoint = os.Getenv("TF_ACC_SKE_CUSTOM_ENDPOINT")
)
// Provider config helper functions
func ObservabilityProviderConfig() string {
if ObservabilityCustomEndpoint == "" {
return `provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
observability_custom_endpoint = "%s"
}`,
ObservabilityCustomEndpoint,
)
}
func CdnProviderConfig() string {
if CdnCustomEndpoint == "" {
return `
provider "stackit" {
enable_beta_resources = true
}`
}
return fmt.Sprintf(`
provider "stackit" {
cdn_custom_endpoint = "%s"
enable_beta_resources = true
}`,
CdnCustomEndpoint,
)
}
func DnsProviderConfig() string {
if DnsCustomEndpoint == "" {
return `provider "stackit" {}`
}
return fmt.Sprintf(`
provider "stackit" {
dns_custom_endpoint = "%s"
}`,
DnsCustomEndpoint,
)
}
func IaaSProviderConfig() string {
if IaaSCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
iaas_custom_endpoint = "%s"
}`,
IaaSCustomEndpoint,
)
}
func IaaSProviderConfigWithBetaResourcesEnabled() string {
if IaaSCustomEndpoint == "" {
return `
provider "stackit" {
enable_beta_resources = true
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
enable_beta_resources = true
iaas_custom_endpoint = "%s"
}`,
IaaSCustomEndpoint,
)
}
func IaaSProviderConfigWithExperiments() string {
if IaaSCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
experiments = [ "routing-tables", "network" ]
}`
}
return fmt.Sprintf(`
provider "stackit" {
iaas_custom_endpoint = "%s"
experiments = [ "routing-tables", "network" ]
}`,
IaaSCustomEndpoint,
)
}
func KMSProviderConfig() string {
if KMSCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
kms_custom_endpoint = "%s"
}`,
KMSCustomEndpoint,
)
}
func LoadBalancerProviderConfig() string {
if LoadBalancerCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
loadbalancer_custom_endpoint = "%s"
}`,
LoadBalancerCustomEndpoint,
)
}
func LogMeProviderConfig() string {
if LogMeCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
logme_custom_endpoint = "%s"
}`,
LogMeCustomEndpoint,
)
}
func MariaDBProviderConfig() string {
if MariaDBCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
mariadb_custom_endpoint = "%s"
}`,
MariaDBCustomEndpoint,
)
}
func ModelServingProviderConfig() string {
if ModelServingCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}
`
}
return fmt.Sprintf(`
provider "stackit" {
modelserving_custom_endpoint = "%s"
}`,
ModelServingCustomEndpoint,
)
}
func MongoDBFlexProviderConfig() string {
if MongoDBFlexCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
mongodbflex_custom_endpoint = "%s"
}`,
MongoDBFlexCustomEndpoint,
)
}
func ObjectStorageProviderConfig() string {
if ObjectStorageCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
objectstorage_custom_endpoint = "%s"
}`,
ObjectStorageCustomEndpoint,
)
}
func OpenSearchProviderConfig() string {
if OpenSearchCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
opensearch_custom_endpoint = "%s"
}`,
OpenSearchCustomEndpoint,
)
}
func PostgresFlexProviderConfig() string {
if PostgresFlexCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
postgresflex_custom_endpoint = "%s"
}`,
PostgresFlexCustomEndpoint,
)
}
func RabbitMQProviderConfig() string {
if RabbitMQCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
rabbitmq_custom_endpoint = "%s"
}`,
RabbitMQCustomEndpoint,
)
}
func RedisProviderConfig() string {
if RedisCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
redis_custom_endpoint = "%s"
}`,
RedisCustomEndpoint,
)
}
func ResourceManagerProviderConfig() string {
token := GetTestProjectServiceAccountToken("")
if ResourceManagerCustomEndpoint == "" || AuthorizationCustomEndpoint == "" {
return fmt.Sprintf(`
provider "stackit" {
service_account_token = "%s"
}`,
token,
)
}
return fmt.Sprintf(`
provider "stackit" {
resourcemanager_custom_endpoint = "%s"
authorization_custom_endpoint = "%s"
service_account_token = "%s"
}`,
ResourceManagerCustomEndpoint,
AuthorizationCustomEndpoint,
token,
)
}
func SecretsManagerProviderConfig() string {
if SecretsManagerCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
secretsmanager_custom_endpoint = "%s"
}`,
SecretsManagerCustomEndpoint,
)
}
func SQLServerFlexProviderConfig() string {
if SQLServerFlexCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
sqlserverflex_custom_endpoint = "%s"
}`,
SQLServerFlexCustomEndpoint,
)
}
func ServerBackupProviderConfig() string {
if ServerBackupCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
enable_beta_resources = true
}`
}
return fmt.Sprintf(`
provider "stackit" {
server_backup_custom_endpoint = "%s"
enable_beta_resources = true
}`,
ServerBackupCustomEndpoint,
)
}
func ServerUpdateProviderConfig() string {
if ServerUpdateCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
enable_beta_resources = true
}`
}
return fmt.Sprintf(`
provider "stackit" {
server_update_custom_endpoint = "%s"
enable_beta_resources = true
}`,
ServerUpdateCustomEndpoint,
)
}
func SKEProviderConfig() string {
if SKECustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
ske_custom_endpoint = "%s"
}`,
SKECustomEndpoint,
)
}
func AuthorizationProviderConfig() string {
if AuthorizationCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
experiments = ["iam"]
}`
}
return fmt.Sprintf(`
provider "stackit" {
authorization_custom_endpoint = "%s"
experiments = ["iam"]
}`,
AuthorizationCustomEndpoint,
)
}
func ServiceAccountProviderConfig() string {
if ServiceAccountCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
enable_beta_resources = true
}`
}
return fmt.Sprintf(`
provider "stackit" {
service_account_custom_endpoint = "%s"
enable_beta_resources = true
}`,
ServiceAccountCustomEndpoint,
)
}
func GitProviderConfig() string {
if GitCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
enable_beta_resources = true
}`
}
return fmt.Sprintf(`
provider "stackit" {
git_custom_endpoint = "%s"
enable_beta_resources = true
}`,
GitCustomEndpoint,
)
}
func ScfProviderConfig() string {
if ScfCustomEndpoint == "" {
return `
provider "stackit" {
default_region = "eu01"
}`
}
return fmt.Sprintf(`
provider "stackit" {
default_region = "eu01"
scf_custom_endpoint = "%s"
}`,
ScfCustomEndpoint,
)
}
func ResourceNameWithDateTime(name string) string {
dateTime := time.Now().Format(time.RFC3339)
// Remove timezone to have a smaller datetime
dateTimeTrimmed, _, _ := strings.Cut(dateTime, "+")
return fmt.Sprintf("tf-acc-%s-%s", name, dateTimeTrimmed)
}
func GetTestProjectServiceAccountToken(path string) string {
var err error
token, tokenSet := os.LookupEnv("TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_TOKEN")
if !tokenSet || token == "" {
token, err = readTestTokenFromCredentialsFile(path)
if err != nil {
return ""
}
}
return token
}
func readTestTokenFromCredentialsFile(path string) (string, error) {
if path == "" {
customPath, customPathSet := os.LookupEnv("STACKIT_CREDENTIALS_PATH")
if !customPathSet || customPath == "" {
path = credentialsFilePath
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("getting home directory: %w", err)
}
path = filepath.Join(home, path)
} else {
path = customPath
}
}
credentialsRaw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
var credentials struct {
TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_TOKEN string `json:"TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_TOKEN"`
}
err = json.Unmarshal(credentialsRaw, &credentials)
if err != nil {
return "", fmt.Errorf("unmarshalling credentials: %w", err)
}
return credentials.TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_TOKEN, nil
}
func getenv(key, defaultValue string) string {
val := os.Getenv(key)
if val == "" {
return defaultValue
}
return val
}
// CreateDefaultLocalFile is a helper for local_file_path. No real data is created
func CreateDefaultLocalFile() os.File {
// Define the file name and size
fileName := "test-512k.img"
size := 512 * 1024 // 512 KB
// Create the file
file, err := os.Create(fileName)
if err != nil {
panic(err)
}
// Seek to the desired position (512 KB)
_, err = file.Seek(int64(size), 0)
if err != nil {
panic(err)
}
return *file
}
func ConvertConfigVariable(variable config.Variable) string {
tmpByteArray, _ := variable.MarshalJSON()
// In case the variable is a string, the quotes should be removed
if tmpByteArray[0] == '"' && tmpByteArray[len(tmpByteArray)-1] == '"' {
result := string(tmpByteArray[1 : len(tmpByteArray)-1])
// Replace escaped quotes which where added MarshalJSON
rawString := strings.ReplaceAll(result, `\"`, `"`)
return rawString
}
return string(tmpByteArray)
}

View file

@ -0,0 +1,50 @@
// Copyright (c) STACKIT
package testutil
import (
"testing"
"github.com/hashicorp/terraform-plugin-testing/config"
)
func TestConvertConfigVariable(t *testing.T) {
tests := []struct {
name string
variable config.Variable
want string
}{
{
name: "string",
variable: config.StringVariable("test"),
want: "test",
},
{
name: "bool: true",
variable: config.BoolVariable(true),
want: "true",
},
{
name: "bool: false",
variable: config.BoolVariable(false),
want: "false",
},
{
name: "integer",
variable: config.IntegerVariable(10),
want: "10",
},
{
name: "quoted string",
variable: config.StringVariable(`instance =~ ".*"`),
want: `instance =~ ".*"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ConvertConfigVariable(tt.variable); got != tt.want {
t.Errorf("ConvertConfigVariable() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,48 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"fmt"
"time"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
type attributeGetter interface {
GetAttribute(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics
}
func ToTime(ctx context.Context, format string, val types.String, target *time.Time) (diags diag.Diagnostics) {
var err error
text := val.ValueString()
*target, err = time.Parse(format, text)
if err != nil {
core.LogAndAddError(ctx, &diags, "cannot parse date", fmt.Sprintf("cannot parse date %q with format %q: %v", text, format, err))
return diags
}
return diags
}
// GetTimeFromStringAttribute retrieves a string attribute from e.g. a [plan.Plan], [tfsdk.Config] or a [tfsdk.State] and
// converts it to a [time.Time] object with a given format, if possible.
func GetTimeFromStringAttribute(ctx context.Context, attributePath path.Path, source attributeGetter, dateFormat string, target *time.Time) (diags diag.Diagnostics) {
var date types.String
diags.Append(source.GetAttribute(ctx, attributePath, &date)...)
if diags.HasError() {
return diags
}
if date.IsNull() || date.IsUnknown() {
return diags
}
diags.Append(ToTime(ctx, dateFormat, date, target)...)
if diags.HasError() {
return diags
}
return diags
}

View file

@ -0,0 +1,90 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"log"
"testing"
"time"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type attributeGetterFunc func(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics
func (a attributeGetterFunc) GetAttribute(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics {
return a(ctx, attributePath, target)
}
func mustLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
log.Panicf("cannot load location %s: %v", name, err)
}
return loc
}
func TestGetTimeFromString(t *testing.T) {
type args struct {
path path.Path
source attributeGetterFunc
dateFormat string
}
tests := []struct {
name string
args args
wantErr bool
want time.Time
}{
{
name: "simple string",
args: args{
path: path.Root("foo"),
source: func(_ context.Context, _ path.Path, target interface{}) diag.Diagnostics {
t, ok := target.(*types.String)
if !ok {
log.Panicf("wrong type %T", target)
}
*t = types.StringValue("2025-02-06T09:41:00+01:00")
return nil
},
dateFormat: time.RFC3339,
},
want: time.Date(2025, 2, 6, 9, 41, 0, 0, mustLocation("Europe/Berlin")),
},
{
name: "invalid type",
args: args{
path: path.Root("foo"),
source: func(_ context.Context, p path.Path, _ interface{}) (diags diag.Diagnostics) {
diags.AddAttributeError(p, "kapow", "kapow")
return diags
},
dateFormat: time.RFC3339,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var target time.Time
gotDiags := GetTimeFromStringAttribute(context.Background(), tt.args.path, tt.args.source, tt.args.dateFormat, &target)
if tt.wantErr {
if !gotDiags.HasError() {
t.Errorf("expected error")
}
} else {
if gotDiags.HasError() {
t.Errorf("expected no errors, but got %v", gotDiags)
} else {
if want, got := tt.want, target; !want.Equal(got) {
t.Errorf("got wrong date, want %s but got %s", want, got)
}
}
}
})
}
}

View file

@ -0,0 +1,13 @@
// Copyright (c) STACKIT
package utils
import (
"fmt"
"github.com/stackitcloud/stackit-sdk-go/core/config"
)
func UserAgentConfigOption(providerVersion string) config.ConfigurationOption {
return config.WithUserAgent(fmt.Sprintf("stackit-terraform-provider/%s", providerVersion))
}

View file

@ -0,0 +1,48 @@
// Copyright (c) STACKIT
package utils
import (
"reflect"
"testing"
"github.com/stackitcloud/stackit-sdk-go/core/config"
)
func TestUserAgentConfigOption(t *testing.T) {
type args struct {
providerVersion string
}
tests := []struct {
name string
args args
want config.ConfigurationOption
}{
{
name: "TestUserAgentConfigOption",
args: args{
providerVersion: "1.0.0",
},
want: config.WithUserAgent("stackit-terraform-provider/1.0.0"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clientConfigActual := config.Configuration{}
err := tt.want(&clientConfigActual)
if err != nil {
t.Errorf("error applying configuration: %v", err)
}
clientConfigExpected := config.Configuration{}
err = UserAgentConfigOption(tt.args.providerVersion)(&clientConfigExpected)
if err != nil {
t.Errorf("error applying configuration: %v", err)
}
if !reflect.DeepEqual(clientConfigActual, clientConfigExpected) {
t.Errorf("UserAgentConfigOption() = %v, want %v", clientConfigActual, clientConfigExpected)
}
})
}
}

View file

@ -0,0 +1,38 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
// AdaptRegion rewrites the region of a terraform plan
func AdaptRegion(ctx context.Context, configRegion types.String, planRegion *types.String, defaultRegion string, resp *resource.ModifyPlanResponse) {
// Get the intended region. This is either set directly set in the individual
// config or the provider region has to be used
var intendedRegion types.String
if configRegion.IsNull() {
if defaultRegion == "" {
core.LogAndAddError(ctx, &resp.Diagnostics, "set region", "no region defined in config or provider")
return
}
intendedRegion = types.StringValue(defaultRegion)
} else {
intendedRegion = configRegion
}
// check if the currently configured region corresponds to the planned region
// on mismatch override the planned region with the intended region
// and force a replacement of the resource
p := path.Root("region")
if !intendedRegion.Equal(*planRegion) {
resp.RequiresReplace.Append(p)
*planRegion = intendedRegion
}
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, p, *planRegion)...)
}

View file

@ -0,0 +1,89 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestAdaptRegion(t *testing.T) {
type model struct {
Region types.String `tfsdk:"region"`
}
type args struct {
configRegion types.String
defaultRegion string
}
testcases := []struct {
name string
args args
wantErr bool
wantRegion types.String
}{
{
"no configured region, use provider region",
args{
types.StringNull(),
"eu01",
},
false,
types.StringValue("eu01"),
},
{
"no configured region, no provider region => want error",
args{
types.StringNull(),
"",
},
true,
types.StringNull(),
},
{
"configuration region overrides provider region",
args{
types.StringValue("eu01-m"),
"eu01",
},
false,
types.StringValue("eu01-m"),
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
plan := tfsdk.Plan{
Schema: schema.Schema{
Attributes: map[string]schema.Attribute{
"region": schema.StringAttribute{
Required: true,
},
},
},
}
if diags := plan.Set(context.Background(), model{types.StringValue("unknown")}); diags.HasError() {
t.Fatalf("cannot create test model: %v", diags)
}
resp := resource.ModifyPlanResponse{
Plan: plan,
}
configModel := model{
Region: tc.args.configRegion,
}
planModel := model{}
AdaptRegion(context.Background(), configModel.Region, &planModel.Region, tc.args.defaultRegion, &resp)
if diags := resp.Diagnostics; tc.wantErr != diags.HasError() {
t.Errorf("unexpected diagnostics: want err: %v, actual %v", tc.wantErr, diags.Errors())
}
if expected, actual := tc.wantRegion, planModel.Region; !expected.Equal(actual) {
t.Errorf("wrong result region. expect %s but got %s", expected, actual)
}
})
}
}

View file

@ -0,0 +1,71 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
)
type UseStateForUnknownFuncResponse struct {
UseStateForUnknown bool
Diagnostics diag.Diagnostics
}
// UseStateForUnknownIfFunc is a conditional function used in UseStateForUnknownIf
type UseStateForUnknownIfFunc func(context.Context, planmodifier.StringRequest, *UseStateForUnknownFuncResponse)
type useStateForUnknownIf struct {
ifFunc UseStateForUnknownIfFunc
description string
}
// UseStateForUnknownIf returns a plan modifier similar to UseStateForUnknown with a conditional
func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description string) planmodifier.String {
return useStateForUnknownIf{
ifFunc: f,
description: description,
}
}
func (m useStateForUnknownIf) Description(context.Context) string {
return m.description
}
func (m useStateForUnknownIf) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}
func (m useStateForUnknownIf) 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/44348af3923c82a93c64ae7dca906d9850ba956b/resource/schema/stringplanmodifier/use_state_for_unknown.go#L38)
funcResponse := &UseStateForUnknownFuncResponse{}
m.ifFunc(ctx, req, funcResponse)
resp.Diagnostics.Append(funcResponse.Diagnostics...)
if resp.Diagnostics.HasError() {
return
}
if funcResponse.UseStateForUnknown {
resp.PlanValue = req.StateValue
}
}

View file

@ -0,0 +1,130 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
stateValue types.String
planValue types.String
configValue types.String
ifFunc UseStateForUnknownIfFunc
expectedPlanValue types.String
expectedError bool
}{
{
name: "State is Null (Creation)",
stateValue: types.StringNull(),
planValue: types.StringUnknown(),
configValue: types.StringValue("some-config"),
ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) {
// This should not be reached because the state is null
resp.UseStateForUnknown = true
},
expectedPlanValue: types.StringUnknown(),
},
{
name: "Plan is already known - (User updated the value)",
stateValue: types.StringValue("old-state"),
planValue: types.StringValue("new-plan"),
configValue: types.StringValue("new-plan"),
ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) {
// This should not be reached because the plan is known
resp.UseStateForUnknown = true
},
expectedPlanValue: types.StringValue("new-plan"),
},
{
name: "Config is Unknown (Interpolation)",
stateValue: types.StringValue("old-state"),
planValue: types.StringUnknown(),
configValue: types.StringUnknown(),
ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) {
// This should not be reached
resp.UseStateForUnknown = true
},
expectedPlanValue: types.StringUnknown(),
},
{
name: "Condition returns False (Do not use state)",
stateValue: types.StringValue("old-state"),
planValue: types.StringUnknown(),
configValue: types.StringNull(), // Simulating computed only
ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) {
resp.UseStateForUnknown = false
},
expectedPlanValue: types.StringUnknown(),
},
{
name: "Condition returns True (Use state)",
stateValue: types.StringValue("old-state"),
planValue: types.StringUnknown(),
configValue: types.StringNull(),
ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) {
resp.UseStateForUnknown = true
},
expectedPlanValue: types.StringValue("old-state"),
},
{
name: "Func returns Error",
stateValue: types.StringValue("old-state"),
planValue: types.StringUnknown(),
configValue: types.StringNull(),
ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) {
resp.Diagnostics.AddError("Test Error", "Something went wrong")
},
expectedPlanValue: types.StringUnknown(),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Initialize the modifier
modifier := UseStateForUnknownIf(tt.ifFunc, "test description")
// Construct request
req := planmodifier.StringRequest{
StateValue: tt.stateValue,
PlanValue: tt.planValue,
ConfigValue: tt.configValue,
}
// Construct response
// Note: In the framework, resp.PlanValue is initialized to req.PlanValue
// before the modifier is called. We must simulate this.
resp := &planmodifier.StringResponse{
PlanValue: tt.planValue,
}
// Run the modifier
modifier.PlanModifyString(ctx, req, resp)
// Check Errors
if tt.expectedError {
if !resp.Diagnostics.HasError() {
t.Error("Expected error, got none")
}
} else {
if resp.Diagnostics.HasError() {
t.Errorf("Unexpected error: %s", resp.Diagnostics)
}
}
// Check Plan Value
if !resp.PlanValue.Equal(tt.expectedPlanValue) {
t.Errorf("PlanValue mismatch.\nExpected: %s\nGot: %s", tt.expectedPlanValue, resp.PlanValue)
}
})
}
}

View file

@ -0,0 +1,186 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
const (
SKEServiceId = "cloud.stackit.ske"
ModelServingServiceId = "cloud.stackit.model-serving"
)
var (
LegacyProjectRoles = []string{"project.admin", "project.auditor", "project.member", "project.owner"}
)
// ReconcileStringSlices reconciles two string lists by removing elements from the
// first list that are not in the second list and appending elements from the
// second list that are not in the first list.
// This preserves the order of the elements in the first list that are also in
// the second list, which is useful when using ListAttributes in Terraform.
// The source of truth for the order is the first list and the source of truth for the content is the second list.
func ReconcileStringSlices(list1, list2 []string) []string {
// Create a copy of list1 to avoid modifying the original list
list1Copy := append([]string{}, list1...)
// Create a map to quickly check if an element is in list2
inList2 := make(map[string]bool)
for _, elem := range list2 {
inList2[elem] = true
}
// Remove elements from list1Copy that are not in list2
i := 0
for _, elem := range list1Copy {
if inList2[elem] {
list1Copy[i] = elem
i++
}
}
list1Copy = list1Copy[:i]
// Append elements to list1Copy that are in list2 but not in list1Copy
inList1 := make(map[string]bool)
for _, elem := range list1Copy {
inList1[elem] = true
}
for _, elem := range list2 {
if !inList1[elem] {
list1Copy = append(list1Copy, elem)
}
}
return list1Copy
}
func ListValuetoStringSlice(list basetypes.ListValue) ([]string, error) {
result := []string{}
for _, el := range list.Elements() {
elStr, ok := el.(types.String)
if !ok {
return result, fmt.Errorf("expected record to be of type %T, got %T", types.String{}, elStr)
}
result = append(result, elStr.ValueString())
}
return result, nil
}
// SimplifyBackupSchedule removes leading 0s from backup schedule numbers (e.g. "00 00 * * *" becomes "0 0 * * *")
// Needed as the API does it internally and would otherwise cause inconsistent result in Terraform
func SimplifyBackupSchedule(schedule string) string {
regex := regexp.MustCompile(`0+\d+`) // Matches series of one or more zeros followed by a series of one or more digits
simplifiedSchedule := regex.ReplaceAllStringFunc(schedule, func(match string) string {
simplified := strings.TrimLeft(match, "0")
if simplified == "" {
simplified = "0"
}
return simplified
})
return simplifiedSchedule
}
// ConvertPointerSliceToStringSlice safely converts a slice of string pointers to a slice of strings.
func ConvertPointerSliceToStringSlice(pointerSlice []*string) []string {
if pointerSlice == nil {
return []string{}
}
stringSlice := make([]string, 0, len(pointerSlice))
for _, strPtr := range pointerSlice {
if strPtr != nil { // Safely skip any nil pointers in the list
stringSlice = append(stringSlice, *strPtr)
}
}
return stringSlice
}
func IsLegacyProjectRole(role string) bool {
return utils.Contains(LegacyProjectRoles, role)
}
type value interface {
IsUnknown() bool
IsNull() bool
}
// IsUndefined checks if a passed value is unknown or null
func IsUndefined(val value) bool {
return val.IsUnknown() || val.IsNull()
}
// LogError logs errors. In descriptions different messages for http status codes can be passed. When no one matches the defaultDescription will be used
func LogError(ctx context.Context, inputDiags *diag.Diagnostics, err error, summary, defaultDescription string, descriptions map[int]string) {
if err == nil {
return
}
tflog.Error(ctx, fmt.Sprintf("%s. Err: %v", summary, err))
var oapiErr *oapierror.GenericOpenAPIError
ok := errors.As(err, &oapiErr)
if !ok {
core.LogAndAddError(ctx, inputDiags, summary, fmt.Sprintf("Calling API: %v", err))
return
}
var description string
if len(descriptions) != 0 {
description, ok = descriptions[oapiErr.StatusCode]
}
if !ok || description == "" {
description = defaultDescription
}
core.LogAndAddError(ctx, inputDiags, summary, description)
}
// FormatPossibleValues formats a slice into a comma-separated-list for usage in the provider docs
func FormatPossibleValues(values ...string) string {
var formattedValues []string
for _, value := range values {
formattedValues = append(formattedValues, fmt.Sprintf("`%v`", value))
}
return fmt.Sprintf("Possible values are: %s.", strings.Join(formattedValues, ", "))
}
func BuildInternalTerraformId(idParts ...string) types.String {
return types.StringValue(strings.Join(idParts, core.Separator))
}
// If a List was completely removed from the terraform config this is not recognized by terraform.
// This helper function checks if that is the case and adjusts the plan accordingly.
func CheckListRemoval(ctx context.Context, configModelList, planModelList types.List, destination path.Path, listType attr.Type, createEmptyList bool, resp *resource.ModifyPlanResponse) {
if configModelList.IsNull() && !planModelList.IsNull() {
if createEmptyList {
emptyList, _ := types.ListValueFrom(ctx, listType, []string{})
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, destination, emptyList)...)
} else {
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, destination, types.ListNull(listType))...)
}
}
}
// SetAndLogStateFields writes the given map of key-value pairs to the state
func SetAndLogStateFields(ctx context.Context, diags *diag.Diagnostics, state *tfsdk.State, values map[string]any) {
for key, val := range values {
ctx = tflog.SetField(ctx, key, val)
diags.Append(state.SetAttribute(ctx, path.Root(key), val)...)
}
}

View file

@ -0,0 +1,614 @@
// Copyright (c) STACKIT
package utils
import (
"context"
"fmt"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
)
func TestReconcileStrLists(t *testing.T) {
tests := []struct {
description string
list1 []string
list2 []string
expected []string
}{
{
"empty lists",
[]string{},
[]string{},
[]string{},
},
{
"list1 empty",
[]string{},
[]string{"a", "b", "c"},
[]string{"a", "b", "c"},
},
{
"list2 empty",
[]string{"a", "b", "c"},
[]string{},
[]string{},
},
{
"no common elements",
[]string{"a", "b", "c"},
[]string{"d", "e", "f"},
[]string{"d", "e", "f"},
},
{
"common elements",
[]string{"d", "a", "c"},
[]string{"b", "c", "d", "e"},
[]string{"d", "c", "b", "e"},
},
{
"common elements with empty string",
[]string{"d", "", "c"},
[]string{"", "c", "d"},
[]string{"d", "", "c"},
},
{
"common elements with duplicates",
[]string{"a", "b", "c", "c"},
[]string{"b", "c", "d", "e"},
[]string{"b", "c", "c", "d", "e"},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output := ReconcileStringSlices(tt.list1, tt.list2)
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
})
}
}
func TestListValuetoStrSlice(t *testing.T) {
tests := []struct {
description string
input basetypes.ListValue
expected []string
isValid bool
}{
{
description: "empty list",
input: types.ListValueMust(types.StringType, []attr.Value{}),
expected: []string{},
isValid: true,
},
{
description: "values ok",
input: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("a"),
types.StringValue("b"),
types.StringValue("c"),
}),
expected: []string{"a", "b", "c"},
isValid: true,
},
{
description: "different type",
input: types.ListValueMust(types.Int64Type, []attr.Value{
types.Int64Value(12),
}),
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := ListValuetoStringSlice(tt.input)
if err != nil {
if !tt.isValid {
return
}
t.Fatalf("Should not have failed: %v", err)
}
if !tt.isValid {
t.Fatalf("Should have failed")
}
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
})
}
}
func TestConvertPointerSliceToStringSlice(t *testing.T) {
tests := []struct {
description string
input []*string
expected []string
}{
{
description: "nil slice",
input: nil,
expected: []string{},
},
{
description: "empty slice",
input: []*string{},
expected: []string{},
},
{
description: "slice with valid pointers",
input: []*string{utils.Ptr("apple"), utils.Ptr("banana"), utils.Ptr("cherry")},
expected: []string{"apple", "banana", "cherry"},
},
{
description: "slice with some nil pointers",
input: []*string{utils.Ptr("apple"), nil, utils.Ptr("cherry"), nil},
expected: []string{"apple", "cherry"},
},
{
description: "slice with all nil pointers",
input: []*string{nil, nil, nil},
expected: []string{},
},
{
description: "slice with a pointer to an empty string",
input: []*string{utils.Ptr("apple"), utils.Ptr(""), utils.Ptr("cherry")},
expected: []string{"apple", "", "cherry"},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output := ConvertPointerSliceToStringSlice(tt.input)
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
})
}
}
func TestSimplifyBackupSchedule(t *testing.T) {
tests := []struct {
description string
input string
expected string
}{
{
"simple schedule",
"0 0 * * *",
"0 0 * * *",
},
{
"schedule with leading zeros",
"00 00 * * *",
"0 0 * * *",
},
{
"schedule with leading zeros 2",
"00 001 * * *",
"0 1 * * *",
},
{
"schedule with leading zeros 3",
"00 0010 * * *",
"0 10 * * *",
},
{
"simple schedule with slash",
"0 0/6 * * *",
"0 0/6 * * *",
},
{
"schedule with leading zeros and slash",
"00 00/6 * * *",
"0 0/6 * * *",
},
{
"schedule with leading zeros and slash 2",
"00 001/06 * * *",
"0 1/6 * * *",
},
{
"simple schedule with comma",
"0 10,15 * * *",
"0 10,15 * * *",
},
{
"schedule with leading zeros and comma",
"0 010,0015 * * *",
"0 10,15 * * *",
},
{
"simple schedule with comma and slash",
"0 0-11/10 * * *",
"0 0-11/10 * * *",
},
{
"schedule with leading zeros, comma, and slash",
"00 000-011/010 * * *",
"0 0-11/10 * * *",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output := SimplifyBackupSchedule(tt.input)
if output != tt.expected {
t.Fatalf("Data does not match: %s", output)
}
})
}
}
func TestIsLegacyProjectRole(t *testing.T) {
tests := []struct {
description string
role string
expected bool
}{
{
"non legacy role",
"owner",
false,
},
{
"leagcy role",
"project.owner",
true,
},
{
"leagcy role 2",
"project.admin",
true,
},
{
"leagcy role 3",
"project.member",
true,
},
{
"leagcy role 4",
"project.auditor",
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output := IsLegacyProjectRole(tt.role)
if output != tt.expected {
t.Fatalf("Data does not match: %v", output)
}
})
}
}
func TestFormatPossibleValues(t *testing.T) {
gotPrefix := "Possible values are:"
type args struct {
values []string
}
tests := []struct {
name string
args args
want string
}{
{
name: "single string value",
args: args{
values: []string{"foo"},
},
want: fmt.Sprintf("%s `foo`.", gotPrefix),
},
{
name: "multiple string value",
args: args{
values: []string{"foo", "bar", "trololol"},
},
want: fmt.Sprintf("%s `foo`, `bar`, `trololol`.", gotPrefix),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FormatPossibleValues(tt.args.values...); got != tt.want {
t.Errorf("FormatPossibleValues() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsUndefined(t *testing.T) {
type args struct {
val value
}
tests := []struct {
name string
args args
want bool
}{
{
name: "undefined value",
args: args{
val: types.StringNull(),
},
want: true,
},
{
name: "unknown value",
args: args{
val: types.StringUnknown(),
},
want: true,
},
{
name: "string value",
args: args{
val: types.StringValue(""),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsUndefined(tt.args.val); got != tt.want {
t.Errorf("IsUndefined() = %v, want %v", got, tt.want)
}
})
}
}
func TestBuildInternalTerraformId(t *testing.T) {
type args struct {
idParts []string
}
tests := []struct {
name string
args args
want types.String
}{
{
name: "no id parts",
args: args{
idParts: []string{},
},
want: types.StringValue(""),
},
{
name: "multiple id parts",
args: args{
idParts: []string{"abc", "foo", "bar", "xyz"},
},
want: types.StringValue("abc,foo,bar,xyz"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := BuildInternalTerraformId(tt.args.idParts...); !reflect.DeepEqual(got, tt.want) {
t.Errorf("BuildInternalTerraformId() = %v, want %v", got, tt.want)
}
})
}
}
func TestCheckListRemoval(t *testing.T) {
type model struct {
AllowedAddresses types.List `tfsdk:"allowed_addresses"`
}
tests := []struct {
description string
configModelList types.List
planModelList types.List
path path.Path
listType attr.Type
createEmptyList bool
expectedAdjustedResp bool
}{
{
"config and plan are the same - no change",
types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("value1"),
}),
types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("value1"),
}),
path.Root("allowed_addresses"),
types.StringType,
false,
false,
},
{
"list was removed from config",
types.ListNull(types.StringType),
types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("value1"),
}),
path.Root("allowed_addresses"),
types.StringType,
false,
true,
},
{
"list was added to config",
types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("value1"),
}),
types.ListNull(types.StringType),
path.Root("allowed_addresses"),
types.StringType,
false,
false,
},
{
"no list provided at all",
types.ListNull(types.StringType),
types.ListNull(types.StringType),
path.Root("allowed_addresses"),
types.StringType,
false,
false,
},
{
"create empty list test - list was removed from config",
types.ListNull(types.StringType),
types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("value1"),
}),
path.Root("allowed_addresses"),
types.StringType,
true,
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
// create resp
plan := tfsdk.Plan{
Schema: schema.Schema{
Attributes: map[string]schema.Attribute{
"allowed_addresses": schema.ListAttribute{
ElementType: basetypes.StringType{},
},
},
},
}
// set input planModelList to plan
if diags := plan.Set(context.Background(), model{tt.planModelList}); diags.HasError() {
t.Fatalf("cannot create test model: %v", diags)
}
resp := resource.ModifyPlanResponse{
Plan: plan,
}
CheckListRemoval(context.Background(), tt.configModelList, tt.planModelList, tt.path, tt.listType, tt.createEmptyList, &resp)
// check targetList
var respList types.List
resp.Plan.GetAttribute(context.Background(), tt.path, &respList)
if tt.createEmptyList {
emptyList, _ := types.ListValueFrom(context.Background(), tt.listType, []string{})
diffEmptyList := cmp.Diff(emptyList, respList)
if diffEmptyList != "" {
t.Fatalf("an empty list should have been created but was not: %s", diffEmptyList)
}
}
// compare planModelList and resp list
diff := cmp.Diff(tt.planModelList, respList)
if tt.expectedAdjustedResp {
if diff == "" {
t.Fatalf("plan should be adjusted but was not")
}
} else {
if diff != "" {
t.Fatalf("plan should not be adjusted but diff is: %s", diff)
}
}
})
}
}
func TestSetAndLogStateFields(t *testing.T) {
testSchema := schema.Schema{
Attributes: map[string]schema.Attribute{
"project_id": schema.StringAttribute{},
"instance_id": schema.StringAttribute{},
},
}
type args struct {
diags *diag.Diagnostics
state *tfsdk.State
values map[string]interface{}
}
type want struct {
hasError bool
state *tfsdk.State
}
tests := []struct {
name string
args args
want want
}{
{
name: "empty map",
args: args{
diags: &diag.Diagnostics{},
state: &tfsdk.State{},
values: map[string]interface{}{},
},
want: want{
hasError: false,
state: &tfsdk.State{},
},
},
{
name: "base",
args: args{
diags: &diag.Diagnostics{},
state: func() *tfsdk.State {
ctx := context.Background()
state := tfsdk.State{
Raw: tftypes.NewValue(testSchema.Type().TerraformType(ctx), map[string]tftypes.Value{
"project_id": tftypes.NewValue(tftypes.String, "9b15d120-86f8-45f5-81d8-a554f09c7582"),
"instance_id": tftypes.NewValue(tftypes.String, nil),
}),
Schema: testSchema,
}
return &state
}(),
values: map[string]interface{}{
"project_id": "a414f971-3f7a-4e9a-8671-51a8acb7bcc8",
"instance_id": "97073250-8cad-46c3-8424-6258ac0b3731",
},
},
want: want{
hasError: false,
state: func() *tfsdk.State {
ctx := context.Background()
state := tfsdk.State{
Raw: tftypes.NewValue(testSchema.Type().TerraformType(ctx), map[string]tftypes.Value{
"project_id": tftypes.NewValue(tftypes.String, nil),
"instance_id": tftypes.NewValue(tftypes.String, nil),
}),
Schema: testSchema,
}
state.SetAttribute(ctx, path.Root("project_id"), "a414f971-3f7a-4e9a-8671-51a8acb7bcc8")
state.SetAttribute(ctx, path.Root("instance_id"), "97073250-8cad-46c3-8424-6258ac0b3731")
return &state
}(),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
SetAndLogStateFields(ctx, tt.args.diags, tt.args.state, tt.args.values)
if tt.args.diags.HasError() != tt.want.hasError {
t.Errorf("TestSetAndLogStateFields() error count = %v, hasErr %v", tt.args.diags.ErrorsCount(), tt.want.hasError)
}
diff := cmp.Diff(tt.args.state, tt.want.state)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
})
}
}

View file

@ -0,0 +1,373 @@
// Copyright (c) STACKIT
package validate
import (
"context"
"fmt"
"net"
"os"
"regexp"
"strings"
"time"
_ "time/tzdata"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/teambition/rrule-go"
)
const (
MajorMinorVersionRegex = `^\d+\.\d+?$`
FullVersionRegex = `^\d+\.\d+.\d+?$`
)
type Validator struct {
description string
markdownDescription string
validate ValidationFn
}
type ValidationFn func(context.Context, validator.StringRequest, *validator.StringResponse)
var _ = validator.String(&Validator{})
func (v *Validator) Description(_ context.Context) string {
return v.description
}
func (v *Validator) MarkdownDescription(_ context.Context) string {
return v.markdownDescription
}
func (v *Validator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // nolint:gocritic // function signature required by Terraform
if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
return
}
v.validate(ctx, req, resp)
}
func UUID() *Validator {
description := "value must be an UUID"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
if _, err := uuid.Parse(req.ConfigValue.ValueString()); err != nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func NoUUID() *Validator {
description := "value must not be an UUID"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
if _, err := uuid.Parse(req.ConfigValue.ValueString()); err == nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
// IP returns a validator that checks, if the given string is a valid IP address.
// The allowZeroAddress parameter defines, if 0.0.0.0, resp. [::] should be considered valid.
func IP(allowZeroAddress bool) *Validator {
description := "value must be an IP address"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
ip := net.ParseIP(req.ConfigValue.ValueString())
invalidZeroAddress := !allowZeroAddress && (net.IPv4zero.Equal(ip) || net.IPv6zero.Equal(ip))
if ip == nil || invalidZeroAddress {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func RecordSet() *Validator {
const typePath = "type"
return &Validator{
description: "value must be a valid record set",
validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
recordType := basetypes.StringValue{}
req.Config.GetAttribute(ctx, path.Root(typePath), &recordType)
switch recordType.ValueString() {
case "A":
ip := net.ParseIP(req.ConfigValue.ValueString())
if ip == nil || ip.To4() == nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
"value must be an IPv4 address",
req.ConfigValue.ValueString(),
))
}
case "AAAA":
ip := net.ParseIP(req.ConfigValue.ValueString())
if ip == nil || ip.To4() != nil || ip.To16() == nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
"value must be an IPv6 address",
req.ConfigValue.ValueString(),
))
}
case "CNAME":
name := req.ConfigValue.ValueString()
if name == "" || name[len(name)-1] != '.' {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
"value must be a Fully Qualified Domain Name (FQDN) and end with dot '.'",
req.ConfigValue.ValueString(),
))
}
case "NS":
case "MX":
case "TXT":
case "ALIAS":
case "DNAME":
case "CAA":
default:
}
},
}
}
func NoSeparator() *Validator {
description := fmt.Sprintf("value must not contain identifier separator '%s'", core.Separator)
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
if strings.Contains(req.ConfigValue.ValueString(), core.Separator) {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func NonLegacyProjectRole() *Validator {
description := "legacy roles are not supported"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
if utils.IsLegacyProjectRole(req.ConfigValue.ValueString()) {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func MinorVersionNumber() *Validator {
description := "value must be a minor version number, without a leading 'v': '[MAJOR].[MINOR]'"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
exp := MajorMinorVersionRegex
r := regexp.MustCompile(exp)
version := req.ConfigValue.ValueString()
if !r.MatchString(version) {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func VersionNumber() *Validator {
description := "value must be a version number, without a leading 'v': '[MAJOR].[MINOR]' or '[MAJOR].[MINOR].[PATCH]'"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
minorVersionExp := MajorMinorVersionRegex
minorVersionRegex := regexp.MustCompile(minorVersionExp)
versionExp := FullVersionRegex
versionRegex := regexp.MustCompile(versionExp)
version := req.ConfigValue.ValueString()
if !minorVersionRegex.MatchString(version) && !versionRegex.MatchString(version) {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func RFC3339SecondsOnly() *Validator {
description := "value must be in RFC339 format (seconds only)"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
t, err := time.Parse(time.RFC3339, req.ConfigValue.ValueString())
if err != nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
return
}
// Check if it failed because it has nanoseconds
if t.Nanosecond() != 0 {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
"value can't have fractional seconds",
req.ConfigValue.ValueString(),
))
}
},
}
}
func CIDR() *Validator {
description := "value must be in CIDR notation"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
_, _, err := net.ParseCIDR(req.ConfigValue.ValueString())
if err != nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
"parsing value in CIDR notation: invalid CIDR address",
req.ConfigValue.ValueString(),
))
}
},
}
}
func Rrule() *Validator {
description := "value must be in a valid RRULE format"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
// The go library rrule-go expects \n before RRULE (to be a newline and not a space)
// for example: "DTSTART;TZID=America/New_York:19970902T090000\nRRULE:FREQ=DAILY;COUNT=10"
// whereas a valid rrule according to the API docs is:
// for example: "DTSTART;TZID=America/New_York:19970902T090000 RRULE:FREQ=DAILY;COUNT=10"
//
// So we will accept a ' ' (which is valid per API docs),
// but replace it with a '\n' for the rrule-go validations
value := req.ConfigValue.ValueString()
value = strings.ReplaceAll(value, " ", "\n")
if _, err := rrule.StrToRRuleSet(value); err != nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func FileExists() *Validator {
description := "file must exist"
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
_, err := os.Stat(req.ConfigValue.ValueString())
if err != nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
func ValidDurationString() *Validator {
description := "value must be in a valid duration string. Such as \"300ms\", \"-1.5h\" or \"2h45m\".\nValid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\"."
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
_, err := time.ParseDuration(req.ConfigValue.ValueString())
if err != nil {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
req.ConfigValue.ValueString(),
))
}
},
}
}
// ValidNoTrailingNewline returns a Validator that checks if the input string has no trailing newline
// character ("\n" or "\r\n"). If a trailing newline is present, a diagnostic error will be appended.
func ValidNoTrailingNewline() *Validator {
description := `The value must not have a trailing newline character ("\n" or "\r\n"). You can remove a trailing newline by using Terraform's built-in chomp() function.`
return &Validator{
description: description,
validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
val := req.ConfigValue.ValueString()
if val == "" {
return
}
if len(val) >= 2 && val[len(val)-2:] == "\r\n" {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
val,
))
return
}
if val[len(val)-1] == '\n' {
resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
description,
val,
))
}
},
}
}

View file

@ -0,0 +1,970 @@
// Copyright (c) STACKIT
package validate
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)
func TestUUID(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"cae27bba-c43d-498a-861e-d11d241c4ff8",
true,
},
{
"too short",
"a-b-c-d",
false,
},
{
"Empty",
"",
false,
},
{
"not UUID",
"www-541-%",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
UUID().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestNoUUID(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"UUID",
"cae27bba-c43d-498a-861e-d11d241c4ff8",
false,
},
{
"no UUID",
"a-b-c-d",
true,
},
{
"Empty",
"",
true,
},
{
"domain name",
"www.test.de",
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
NoUUID().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestIP(t *testing.T) {
tests := []struct {
description string
invalidZero bool
input string
isValid bool
}{
{
"ok IP4",
false,
"111.222.111.222",
true,
},
{
"ok IP6",
false,
"2001:0db8:85a3:08d3::0370:7344",
true,
},
{
"too short",
false,
"0.1.2",
false,
},
{
"Empty",
false,
"",
false,
},
{
"Not an IP",
false,
"for-sure-not-an-IP",
false,
},
{
"valid ipv4 zero",
true,
"0.0.0.0",
true,
},
{
"invalid ipv4 zero",
false,
"0.0.0.0",
false,
},
{
"valid ipv6 zero",
true,
"::",
true,
},
{
"valid ipv6 zero short notation",
true,
"::0",
true,
},
{
"valid ipv6 zero long notation",
true,
"0000:0000:0000:0000:0000:0000:0000:0000",
true,
},
{
"invalid ipv6 zero short notation",
false,
"::",
false,
},
{
"invalid ipv6 zero long notation",
false,
"0000:0000:0000:0000:0000:0000:0000:0000",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
IP(tt.invalidZero).ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestRecordSet(t *testing.T) {
tests := []struct {
description string
record string
recordType string
isValid bool
}{
{
"A record ok IP4",
"111.222.111.222",
"A",
true,
},
{
"A record fail IP6",
"2001:0db8:85a3:08d3::0370:7344",
"A",
false,
},
{
"A record too short",
"0.1.2",
"A",
false,
},
{
"A record Empty",
"",
"A",
false,
},
{
"A record Not an IP",
"for-sure-not-an-IP",
"A",
false,
},
{
"AAAA record fail IP4",
"111.222.111.222",
"AAAA",
false,
},
{
"AAAA record ok IP6",
"2001:0db8:85a3:08d3::0370:7344",
"AAAA",
true,
},
{
"AAAA record too short",
"0.1.2",
"AAAA",
false,
},
{
"AAAA record Empty",
"",
"AAAA",
false,
},
{
"AAAA record Not an IP",
"for-sure-not-an-IP",
"AAAA",
false,
},
{
"CNAME record Not a Fully Qualified Domain Name",
"stackit.de",
"CNAME",
false,
},
{
"CNAME record ok Fully Qualified Domain Name",
"stackit.de.",
"CNAME",
true,
},
{
"NS record",
"some-record",
"NS",
true,
},
{
"MX record",
"some-record",
"MX",
true,
},
{
"TXT record",
"some-record",
"TXT",
true,
},
{
"ALIAS record",
"some-record",
"ALIAS",
true,
},
{
"DNAME record",
"some-record",
"DNAME",
true,
},
{
"CAA record",
"some-record",
"CAA",
true,
},
{
"random record",
"some-record",
"random",
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
scheme := tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"type": tftypes.String,
},
}
value := map[string]tftypes.Value{
"type": tftypes.NewValue(tftypes.String, tt.recordType),
}
record := tftypes.NewValue(scheme, value)
RecordSet().ValidateString(context.Background(), validator.StringRequest{
Config: tfsdk.Config{
Schema: schema.Schema{
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{},
},
},
Raw: record,
},
ConfigValue: types.StringValue(tt.record),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestNoSeparator(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"ABCD",
true,
},
{
"ok-2",
"#$%&/()=.;-",
true,
},
{
"Empty",
"",
true,
},
{
"not ok",
"ab,",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
NoSeparator().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestNonLegacyProjectRole(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"owner",
true,
},
{
"ok-2",
"reader",
true,
},
{
"leagcy-role",
"project.owner",
false,
},
{
"leagcy-role-2",
"project.admin",
false,
},
{
"leagcy-role-3",
"project.member",
false,
},
{
"leagcy-role-4",
"project.auditor",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
NonLegacyProjectRole().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestMinorVersionNumber(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"1.20",
true,
},
{
"ok-2",
"1.3",
true,
},
{
"ok-3",
"10.1",
true,
},
{
"Empty",
"",
false,
},
{
"not ok",
"afssfdfs",
false,
},
{
"not ok-major-version",
"1",
false,
},
{
"not ok-patch-version",
"1.20.1",
false,
},
{
"not ok-version",
"v1.20.1",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
MinorVersionNumber().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestVersionNumber(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"1.20",
true,
},
{
"ok-2",
"1.3",
true,
},
{
"ok-3",
"10.1",
true,
},
{
"ok-patch-version",
"1.20.1",
true,
},
{
"ok-patch-version-2",
"1.20.10",
true,
},
{
"ok-patch-version-3",
"10.20.10",
true,
},
{
"Empty",
"",
false,
},
{
"not ok",
"afssfdfs",
false,
},
{
"not ok-major-version",
"1",
false,
},
{
"not ok-version",
"v1.20.1",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
VersionNumber().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestRFC3339SecondsOnly(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"9999-01-02T03:04:05Z",
true,
},
{
"ok_2",
"9999-01-02T03:04:05+06:00",
true,
},
{
"empty",
"",
false,
},
{
"not_ok",
"foo-bar",
false,
},
{
"with_sub_seconds",
"9999-01-02T03:04:05.678Z",
false,
},
{
"with_sub_seconds_2",
"9999-01-02T03:04:05.678+06:00",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
RFC3339SecondsOnly().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestCIDR(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"IPv4_block",
"198.51.100.14/24",
true,
},
{
"IPv4_block_2",
"111.222.111.222/22",
true,
},
{
"IPv4_single",
"198.51.100.14/32",
true,
},
{
"IPv4_entire_internet",
"0.0.0.0/0",
true,
},
{
"IPv4_block_invalid",
"198.51.100.14/33",
false,
},
{
"IPv4_no_block",
"111.222.111.222",
false,
},
{
"IPv6_block",
"2001:db8::/48",
true,
},
{
"IPv6_single",
"2001:0db8:85a3:08d3::0370:7344/128",
true,
},
{
"IPv6_all",
"::/0",
true,
},
{
"IPv6_block_invalid",
"2001:0db8:85a3:08d3::0370:7344/129",
false,
},
{
"IPv6_no_block",
"2001:0db8:85a3:08d3::0370:7344",
false,
},
{
"empty",
"",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
CIDR().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestRrule(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1",
true,
},
{
"ok-2",
"DTSTART;TZID=Europe/Sofia:20200803T023000\nRULE:FREQ=DAILY;INTERVAL=1",
true,
},
{
"Empty",
"",
false,
},
{
"not ok",
"afssfdfs",
false,
},
{
"not ok-missing-space-before-rrule",
"DTSTART;TZID=Europe/Sofia:20200803T023000RRULE:FREQ=DAILY;INTERVAL=1",
false,
},
{
"not ok-missing-interval",
"DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
Rrule().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestFileExists(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"ok",
"testdata/file.txt",
true,
},
{
"not ok",
"testdata/non-existing-file.txt",
false,
},
{
"empty",
"",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
FileExists().ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Should have failed")
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors())
}
})
}
}
func TestValidTtlDuration(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"valid duration with hours, minutes, and seconds",
"5h30m40s",
true,
},
{
"valid duration with hours only",
"5h",
true,
},
{
"valid duration with hours and minutes",
"5h30m",
true,
},
{
"valid duration with minutes only",
"30m",
true,
},
{
"valid duration with seconds only",
"30s",
true,
},
{
"invalid duration with incorrect unit",
"30o",
false,
},
{
"invalid duration without unit",
"30",
false,
},
{
"invalid duration with invalid letters",
"30e",
false,
},
{
"invalid duration with letters in middle",
"1h30x",
false,
},
{
"empty string",
"",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
va := ValidDurationString()
va.ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Expected validation to fail for input: %v", tt.input)
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Expected validation to succeed for input: %v, but got errors: %v", tt.input, r.Diagnostics.Errors())
}
})
}
}
func TestValidNoTrailingNewline(t *testing.T) {
tests := []struct {
description string
input string
isValid bool
}{
{
"string with no trailing newline",
"abc",
true,
},
{
"string with trailing \\n",
"abc\n",
false,
},
{
"string with trailing \\r\\n",
"abc\r\n",
false,
},
{
"string with internal newlines but not trailing",
"abc\ndef\nghi",
true,
},
{
"empty string",
"",
true,
},
{
"string that is just \\n",
"\n",
false,
},
{
"string that is just \\r\\n",
"\r\n",
false,
},
{
"string with multiple newlines, trailing",
"abc\n\n",
false,
},
{
"string with newlines but ends with character",
"abc\ndef\n",
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
r := validator.StringResponse{}
va := ValidNoTrailingNewline()
va.ValidateString(context.Background(), validator.StringRequest{
ConfigValue: types.StringValue(tt.input),
}, &r)
if !tt.isValid && !r.Diagnostics.HasError() {
t.Fatalf("Expected validation to fail for input: %q", tt.input)
}
if tt.isValid && r.Diagnostics.HasError() {
t.Fatalf("Expected validation to succeed for input: %q, but got errors: %v", tt.input, r.Diagnostics.Errors())
}
})
}
}

502
stackit/provider.go Normal file
View file

@ -0,0 +1,502 @@
// Copyright (c) STACKIT
package stackit
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
sdkauth "github.com/stackitcloud/stackit-sdk-go/core/auth"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
postgresFlexAlphaInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflexalpha/instance"
sqlServerFlexAlphaInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflexalpha/instance"
sqlServerFlexAlpaUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflexalpha/user"
)
// Ensure the implementation satisfies the expected interfaces
var (
_ provider.Provider = &Provider{}
)
// Provider is the provider implementation.
type Provider struct {
version string
}
// New is a helper function to simplify provider server and testing implementation.
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &Provider{
version: version,
}
}
}
func (p *Provider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "stackitprivatepreview"
resp.Version = p.version
}
type providerModel struct {
CredentialsFilePath types.String `tfsdk:"credentials_path"`
ServiceAccountEmail types.String `tfsdk:"service_account_email"` // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025
ServiceAccountKey types.String `tfsdk:"service_account_key"`
ServiceAccountKeyPath types.String `tfsdk:"service_account_key_path"`
PrivateKey types.String `tfsdk:"private_key"`
PrivateKeyPath types.String `tfsdk:"private_key_path"`
Token types.String `tfsdk:"service_account_token"`
// Deprecated: Use DefaultRegion instead
Region types.String `tfsdk:"region"`
DefaultRegion types.String `tfsdk:"default_region"`
// Custom endpoints
AuthorizationCustomEndpoint types.String `tfsdk:"authorization_custom_endpoint"`
CdnCustomEndpoint types.String `tfsdk:"cdn_custom_endpoint"`
DnsCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"`
GitCustomEndpoint types.String `tfsdk:"git_custom_endpoint"`
IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"`
KmsCustomEndpoint types.String `tfsdk:"kms_custom_endpoint"`
LoadBalancerCustomEndpoint types.String `tfsdk:"loadbalancer_custom_endpoint"`
LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"`
MariaDBCustomEndpoint types.String `tfsdk:"mariadb_custom_endpoint"`
ModelServingCustomEndpoint types.String `tfsdk:"modelserving_custom_endpoint"`
MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"`
ObjectStorageCustomEndpoint types.String `tfsdk:"objectstorage_custom_endpoint"`
ObservabilityCustomEndpoint types.String `tfsdk:"observability_custom_endpoint"`
OpenSearchCustomEndpoint types.String `tfsdk:"opensearch_custom_endpoint"`
PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"`
RabbitMQCustomEndpoint types.String `tfsdk:"rabbitmq_custom_endpoint"`
RedisCustomEndpoint types.String `tfsdk:"redis_custom_endpoint"`
ResourceManagerCustomEndpoint types.String `tfsdk:"resourcemanager_custom_endpoint"`
ScfCustomEndpoint types.String `tfsdk:"scf_custom_endpoint"`
SecretsManagerCustomEndpoint types.String `tfsdk:"secretsmanager_custom_endpoint"`
ServerBackupCustomEndpoint types.String `tfsdk:"server_backup_custom_endpoint"`
ServerUpdateCustomEndpoint types.String `tfsdk:"server_update_custom_endpoint"`
ServiceAccountCustomEndpoint types.String `tfsdk:"service_account_custom_endpoint"`
ServiceEnablementCustomEndpoint types.String `tfsdk:"service_enablement_custom_endpoint"`
SkeCustomEndpoint types.String `tfsdk:"ske_custom_endpoint"`
SqlServerFlexCustomEndpoint types.String `tfsdk:"sqlserverflex_custom_endpoint"`
TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"`
EnableBetaResources types.Bool `tfsdk:"enable_beta_resources"`
Experiments types.List `tfsdk:"experiments"`
}
// Schema defines the provider-level schema for configuration data.
func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
descriptions := map[string]string{
"credentials_path": "Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`.",
"service_account_token": "Token used for authentication. If set, the token flow will be used to authenticate all operations.",
"service_account_key_path": "Path for the service account key used for authentication. If set, the key flow will be used to authenticate all operations.",
"service_account_key": "Service account key used for authentication. If set, the key flow will be used to authenticate all operations.",
"private_key_path": "Path for the private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key.",
"private_key": "Private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key.",
"service_account_email": "Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL. It is required if you want to use the resource manager project resource.",
"region": "Region will be used as the default location for regional services. Not all services require a region, some are global",
"default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global",
"cdn_custom_endpoint": "Custom endpoint for the CDN service",
"dns_custom_endpoint": "Custom endpoint for the DNS service",
"git_custom_endpoint": "Custom endpoint for the Git service",
"iaas_custom_endpoint": "Custom endpoint for the IaaS service",
"kms_custom_endpoint": "Custom endpoint for the KMS service",
"mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service",
"modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service",
"loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service",
"logme_custom_endpoint": "Custom endpoint for the LogMe service",
"rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service",
"mariadb_custom_endpoint": "Custom endpoint for the MariaDB service",
"authorization_custom_endpoint": "Custom endpoint for the Membership service",
"objectstorage_custom_endpoint": "Custom endpoint for the Object Storage service",
"observability_custom_endpoint": "Custom endpoint for the Observability service",
"opensearch_custom_endpoint": "Custom endpoint for the OpenSearch service",
"postgresflex_custom_endpoint": "Custom endpoint for the PostgresFlex service",
"redis_custom_endpoint": "Custom endpoint for the Redis service",
"server_backup_custom_endpoint": "Custom endpoint for the Server Backup service",
"server_update_custom_endpoint": "Custom endpoint for the Server Update service",
"service_account_custom_endpoint": "Custom endpoint for the Service Account service",
"resourcemanager_custom_endpoint": "Custom endpoint for the Resource Manager service",
"scf_custom_endpoint": "Custom endpoint for the Cloud Foundry (SCF) service",
"secretsmanager_custom_endpoint": "Custom endpoint for the Secrets Manager service",
"sqlserverflex_custom_endpoint": "Custom endpoint for the SQL Server Flex service",
"ske_custom_endpoint": "Custom endpoint for the Kubernetes Engine (SKE) service",
"service_enablement_custom_endpoint": "Custom endpoint for the Service Enablement API",
"token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow",
"enable_beta_resources": "Enable beta resources. Default is false.",
"experiments": fmt.Sprintf(
"Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v",
strings.Join(features.AvailableExperiments, ", "),
),
}
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"credentials_path": schema.StringAttribute{
Optional: true,
Description: descriptions["credentials_path"],
},
"service_account_email": schema.StringAttribute{
Optional: true,
Description: descriptions["service_account_email"],
DeprecationMessage: "The `service_account_email` field has been deprecated because it is not required. Will be removed after June 12th 2025.",
},
"service_account_token": schema.StringAttribute{
Optional: true,
Description: descriptions["service_account_token"],
DeprecationMessage: "Authentication via Service Account Token is deprecated and will be removed on December 17, 2025. " +
"Please use `service_account_key` or `service_account_key_path` instead. " +
"For a smooth transition, refer to our migration guide: https://docs.stackit.cloud/platform/access-and-identity/service-accounts/migrate-flows/",
},
"service_account_key_path": schema.StringAttribute{
Optional: true,
Description: descriptions["service_account_key_path"],
},
"service_account_key": schema.StringAttribute{
Optional: true,
Description: descriptions["service_account_key"],
},
"private_key": schema.StringAttribute{
Optional: true,
Description: descriptions["private_key"],
},
"private_key_path": schema.StringAttribute{
Optional: true,
Description: descriptions["private_key_path"],
},
"region": schema.StringAttribute{
Optional: true,
Description: descriptions["region"],
DeprecationMessage: "This attribute is deprecated. Use 'default_region' instead",
Validators: []validator.String{
stringvalidator.ConflictsWith(path.MatchRoot("default_region")),
},
},
"default_region": schema.StringAttribute{
Optional: true,
Description: descriptions["default_region"],
Validators: []validator.String{
stringvalidator.ConflictsWith(path.MatchRoot("region")),
},
},
"enable_beta_resources": schema.BoolAttribute{
Optional: true,
Description: descriptions["enable_beta_resources"],
},
"experiments": schema.ListAttribute{
ElementType: types.StringType,
Optional: true,
Description: descriptions["experiments"],
},
// Custom endpoints
"cdn_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["cdn_custom_endpoint"],
},
"dns_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["dns_custom_endpoint"],
},
"git_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["git_custom_endpoint"],
},
"iaas_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["iaas_custom_endpoint"],
},
"kms_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["kms_custom_endpoint"],
},
"postgresflex_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["postgresflex_custom_endpoint"],
},
"mariadb_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["mariadb_custom_endpoint"],
},
"modelserving_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["modelserving_custom_endpoint"],
},
"authorization_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["authorization_custom_endpoint"],
},
"mongodbflex_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["mongodbflex_custom_endpoint"],
},
"loadbalancer_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["loadbalancer_custom_endpoint"],
},
"logme_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["logme_custom_endpoint"],
},
"rabbitmq_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["rabbitmq_custom_endpoint"],
},
"objectstorage_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["objectstorage_custom_endpoint"],
},
"observability_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["observability_custom_endpoint"],
},
"opensearch_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["opensearch_custom_endpoint"],
},
"redis_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["redis_custom_endpoint"],
},
"scf_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["scf_custom_endpoint"],
},
"resourcemanager_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["resourcemanager_custom_endpoint"],
},
"secretsmanager_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["secretsmanager_custom_endpoint"],
},
"sqlserverflex_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["sqlserverflex_custom_endpoint"],
},
"ske_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["ske_custom_endpoint"],
},
"server_backup_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["server_backup_custom_endpoint"],
},
"server_update_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["server_update_custom_endpoint"],
},
"service_account_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["service_account_custom_endpoint"],
},
"service_enablement_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["service_enablement_custom_endpoint"],
},
"token_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["token_custom_endpoint"],
},
},
}
}
// Configure prepares a stackit API client for data sources and resources.
func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
// Retrieve provider data and configuration
var providerConfig providerModel
diags := req.Config.Get(ctx, &providerConfig)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Configure SDK client
sdkConfig := &config.Configuration{}
var providerData core.ProviderData
// Helper function to set a string field if it's known and not null
setStringField := func(v basetypes.StringValue, setter func(string)) {
if !v.IsUnknown() && !v.IsNull() {
setter(v.ValueString())
}
}
// Helper function to set a boolean field if it's known and not null
setBoolField := func(v basetypes.BoolValuable, setter func(bool)) {
if !v.IsUnknown() && !v.IsNull() {
val, err := v.ToBoolValue(ctx)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error configuring provider",
fmt.Sprintf("Setting up bool value: %v", diags.Errors()),
)
}
setter(val.ValueBool())
}
}
// Configure SDK client
setStringField(providerConfig.CredentialsFilePath, func(v string) { sdkConfig.CredentialsFilePath = v })
setStringField(providerConfig.ServiceAccountKey, func(v string) { sdkConfig.ServiceAccountKey = v })
setStringField(providerConfig.ServiceAccountKeyPath, func(v string) { sdkConfig.ServiceAccountKeyPath = v })
setStringField(providerConfig.PrivateKey, func(v string) { sdkConfig.PrivateKey = v })
setStringField(providerConfig.PrivateKeyPath, func(v string) { sdkConfig.PrivateKeyPath = v })
setStringField(providerConfig.Token, func(v string) { sdkConfig.Token = v })
setStringField(providerConfig.TokenCustomEndpoint, func(v string) { sdkConfig.TokenCustomUrl = v })
setStringField(providerConfig.DefaultRegion, func(v string) { providerData.DefaultRegion = v })
setStringField(
providerConfig.Region,
func(v string) { providerData.Region = v },
) // nolint:staticcheck // preliminary handling of deprecated attribute
setBoolField(providerConfig.EnableBetaResources, func(v bool) { providerData.EnableBetaResources = v })
setStringField(
providerConfig.AuthorizationCustomEndpoint,
func(v string) { providerData.AuthorizationCustomEndpoint = v },
)
setStringField(providerConfig.CdnCustomEndpoint, func(v string) { providerData.CdnCustomEndpoint = v })
setStringField(providerConfig.DnsCustomEndpoint, func(v string) { providerData.DnsCustomEndpoint = v })
setStringField(providerConfig.GitCustomEndpoint, func(v string) { providerData.GitCustomEndpoint = v })
setStringField(providerConfig.IaaSCustomEndpoint, func(v string) { providerData.IaaSCustomEndpoint = v })
setStringField(providerConfig.KmsCustomEndpoint, func(v string) { providerData.KMSCustomEndpoint = v })
setStringField(
providerConfig.LoadBalancerCustomEndpoint,
func(v string) { providerData.LoadBalancerCustomEndpoint = v },
)
setStringField(providerConfig.LogMeCustomEndpoint, func(v string) { providerData.LogMeCustomEndpoint = v })
setStringField(providerConfig.MariaDBCustomEndpoint, func(v string) { providerData.MariaDBCustomEndpoint = v })
setStringField(
providerConfig.ModelServingCustomEndpoint,
func(v string) { providerData.ModelServingCustomEndpoint = v },
)
setStringField(
providerConfig.MongoDBFlexCustomEndpoint,
func(v string) { providerData.MongoDBFlexCustomEndpoint = v },
)
setStringField(
providerConfig.ObjectStorageCustomEndpoint,
func(v string) { providerData.ObjectStorageCustomEndpoint = v },
)
setStringField(
providerConfig.ObservabilityCustomEndpoint,
func(v string) { providerData.ObservabilityCustomEndpoint = v },
)
setStringField(
providerConfig.OpenSearchCustomEndpoint,
func(v string) { providerData.OpenSearchCustomEndpoint = v },
)
setStringField(
providerConfig.PostgresFlexCustomEndpoint,
func(v string) { providerData.PostgresFlexCustomEndpoint = v },
)
setStringField(providerConfig.RabbitMQCustomEndpoint, func(v string) { providerData.RabbitMQCustomEndpoint = v })
setStringField(providerConfig.RedisCustomEndpoint, func(v string) { providerData.RedisCustomEndpoint = v })
setStringField(
providerConfig.ResourceManagerCustomEndpoint,
func(v string) { providerData.ResourceManagerCustomEndpoint = v },
)
setStringField(providerConfig.ScfCustomEndpoint, func(v string) { providerData.ScfCustomEndpoint = v })
setStringField(
providerConfig.SecretsManagerCustomEndpoint,
func(v string) { providerData.SecretsManagerCustomEndpoint = v },
)
setStringField(
providerConfig.ServerBackupCustomEndpoint,
func(v string) { providerData.ServerBackupCustomEndpoint = v },
)
setStringField(
providerConfig.ServerUpdateCustomEndpoint,
func(v string) { providerData.ServerUpdateCustomEndpoint = v },
)
setStringField(
providerConfig.ServiceAccountCustomEndpoint,
func(v string) { providerData.ServiceAccountCustomEndpoint = v },
)
setStringField(
providerConfig.ServiceEnablementCustomEndpoint,
func(v string) { providerData.ServiceEnablementCustomEndpoint = v },
)
setStringField(providerConfig.SkeCustomEndpoint, func(v string) { providerData.SKECustomEndpoint = v })
setStringField(
providerConfig.SqlServerFlexCustomEndpoint,
func(v string) { providerData.SQLServerFlexCustomEndpoint = v },
)
if !(providerConfig.Experiments.IsUnknown() || providerConfig.Experiments.IsNull()) {
var experimentValues []string
diags := providerConfig.Experiments.ElementsAs(ctx, &experimentValues, false)
if diags.HasError() {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error configuring provider",
fmt.Sprintf("Setting up experiments: %v", diags.Errors()),
)
}
providerData.Experiments = experimentValues
}
roundTripper, err := sdkauth.SetupAuth(sdkConfig)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error configuring provider",
fmt.Sprintf("Setting up authentication: %v", err),
)
return
}
// Make round tripper and custom endpoints available during DataSource and Resource
// type Configure methods.
providerData.RoundTripper = roundTripper
resp.DataSourceData = providerData
resp.ResourceData = providerData
// Copy service account, private key credentials and custom-token endpoint to support ephemeral access token generation
var ephemeralProviderData core.EphemeralProviderData
ephemeralProviderData.ProviderData = providerData
setStringField(providerConfig.ServiceAccountKey, func(v string) { ephemeralProviderData.ServiceAccountKey = v })
setStringField(
providerConfig.ServiceAccountKeyPath,
func(v string) { ephemeralProviderData.ServiceAccountKeyPath = v },
)
setStringField(providerConfig.PrivateKey, func(v string) { ephemeralProviderData.PrivateKey = v })
setStringField(providerConfig.PrivateKeyPath, func(v string) { ephemeralProviderData.PrivateKeyPath = v })
setStringField(providerConfig.TokenCustomEndpoint, func(v string) { ephemeralProviderData.TokenCustomEndpoint = v })
resp.EphemeralResourceData = ephemeralProviderData
providerData.Version = p.version
}
// DataSources defines the data sources implemented in the provider.
func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
sqlServerFlexAlphaInstance.NewInstanceDataSource,
sqlServerFlexAlpaUser.NewUserDataSource,
}
}
// Resources defines the resources implemented in the provider.
func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
resources := []func() resource.Resource{
postgresFlexAlphaInstance.NewInstanceResource,
sqlServerFlexAlphaInstance.NewInstanceResource,
sqlServerFlexAlpaUser.NewUserResource,
}
return resources
}

View file

@ -0,0 +1,261 @@
// Copyright (c) STACKIT
package stackit_test
import (
_ "embed"
"fmt"
"os"
"path"
"regexp"
"runtime"
"testing"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
//go:embed testdata/provider-credentials.tf
var providerCredentialConfig string
//go:embed testdata/provider-invalid-attribute.tf
var providerInvalidAttribute string
//go:embed testdata/provider-all-attributes.tf
var providerValidAttributes string
var testConfigProviderCredentials = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable(fmt.Sprintf("tf-acc-prov%s", acctest.RandStringFromCharSet(3, acctest.CharSetAlphaNum))),
}
// Helper function to obtain the home directory on different systems.
// Based on os.UserHomeDir().
func getHomeEnvVariableName() string {
env := "HOME"
switch runtime.GOOS {
case "windows":
env = "USERPROFILE"
case "plan9":
env = "home"
}
return env
}
// create temporary home and initialize the credentials file as well
func createTemporaryHome(createValidCredentialsFile bool, t *testing.T) string {
// create a temporary file
tempHome, err := os.MkdirTemp("", "tempHome")
if err != nil {
t.Fatalf("Failed to create temporary home directory: %v", err)
}
// create credentials file in temp directory
stackitFolder := path.Join(tempHome, ".stackit")
if err := os.Mkdir(stackitFolder, 0o750); err != nil {
t.Fatalf("Failed to create stackit folder: %v", err)
}
filePath := path.Join(stackitFolder, "credentials.json")
file, err := os.Create(filePath)
if err != nil {
t.Fatalf("Failed to create credentials file: %v", err)
}
defer func() {
if err := file.Close(); err != nil {
t.Fatalf("Error while closing the file: %v", err)
}
}()
// Define content, default = invalid token
token := "foo_token"
if createValidCredentialsFile {
token = testutil.GetTestProjectServiceAccountToken("")
}
content := fmt.Sprintf(`
{
"STACKIT_SERVICE_ACCOUNT_TOKEN": "%s"
}`, token)
if _, err = file.WriteString(content); err != nil {
t.Fatalf("Error writing to file: %v", err)
}
return tempHome
}
// Function to overwrite the home folder
func setTemporaryHome(tempHomePath string) {
env := getHomeEnvVariableName()
if err := os.Setenv(env, tempHomePath); err != nil {
fmt.Printf("Error setting temporary home directory %v", err)
}
}
// cleanup the temporary home and reset the environment variable
func cleanupTemporaryHome(tempHomePath string, t *testing.T) {
if err := os.RemoveAll(tempHomePath); err != nil {
t.Fatalf("Error cleaning up temporary folder: %v", err)
}
originalHomeDir, err := os.UserHomeDir()
if err != nil {
t.Fatalf("Failed to restore home directory back to normal: %v", err)
}
// revert back to original home folder
env := getHomeEnvVariableName()
if err := os.Setenv(env, originalHomeDir); err != nil {
fmt.Printf("Error resetting temporary home directory %v", err)
}
}
func getServiceAccountToken() (string, error) {
token, set := os.LookupEnv("TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_TOKEN")
if !set || token == "" {
return "", fmt.Errorf("Token not set, please set TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_TOKEN to a valid token to perform tests")
}
return token, nil
}
func TestAccEnvVarTokenValid(t *testing.T) {
// Check if acceptance tests should be run
if v := os.Getenv(resource.EnvTfAcc); v == "" {
t.Skipf(
"Acceptance tests skipped unless env '%s' set",
resource.EnvTfAcc)
return
}
token, err := getServiceAccountToken()
if err != nil {
t.Fatalf("Can't get token: %v", err)
}
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", token)
tempHomeFolder := createTemporaryHome(false, t)
defer cleanupTemporaryHome(tempHomeFolder, t)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
PreConfig: func() { setTemporaryHome(tempHomeFolder) },
ConfigVariables: testConfigProviderCredentials,
Config: providerCredentialConfig,
},
},
})
}
func TestAccEnvVarTokenInvalid(t *testing.T) {
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "foo")
tempHomeFolder := createTemporaryHome(false, t)
defer cleanupTemporaryHome(tempHomeFolder, t)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
PreConfig: func() { setTemporaryHome(tempHomeFolder) },
ConfigVariables: testConfigProviderCredentials,
Config: providerCredentialConfig,
ExpectError: regexp.MustCompile(`undefined response type, status code 401`),
},
},
})
}
func TestAccCredentialsFileValid(t *testing.T) {
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "")
tempHomeFolder := createTemporaryHome(true, t)
defer cleanupTemporaryHome(tempHomeFolder, t)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
PreConfig: func() { setTemporaryHome(tempHomeFolder) },
ConfigVariables: testConfigProviderCredentials,
Config: providerCredentialConfig,
},
},
})
}
func TestAccCredentialsFileInvalid(t *testing.T) {
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "")
tempHomeFolder := createTemporaryHome(false, t)
defer cleanupTemporaryHome(tempHomeFolder, t)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
PreConfig: func() { setTemporaryHome(tempHomeFolder) },
ConfigVariables: testConfigProviderCredentials,
Config: providerCredentialConfig,
ExpectError: regexp.MustCompile(`Jwt is not in(\r\n|\r|\n)the form of Header.Payload.Signature`),
},
},
})
}
func TestAccProviderConfigureValidValues(t *testing.T) {
// Check if acceptance tests should be run
if v := os.Getenv(resource.EnvTfAcc); v == "" {
t.Skipf(
"Acceptance tests skipped unless env '%s' set",
resource.EnvTfAcc)
return
}
// use service account token for these tests
token, err := getServiceAccountToken()
if err != nil {
t.Fatalf("Can't get token: %v", err)
}
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", token)
tempHomeFolder := createTemporaryHome(true, t)
defer cleanupTemporaryHome(tempHomeFolder, t)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{ // valid provider attributes
ConfigVariables: testConfigProviderCredentials,
Config: providerValidAttributes,
},
},
})
}
func TestAccProviderConfigureAnInvalidValue(t *testing.T) {
// Check if acceptance tests should be run
if v := os.Getenv(resource.EnvTfAcc); v == "" {
t.Skipf(
"Acceptance tests skipped unless env '%s' set",
resource.EnvTfAcc)
return
}
// use service account token for these tests
token, err := getServiceAccountToken()
if err != nil {
t.Fatalf("Can't get token: %v", err)
}
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", token)
tempHomeFolder := createTemporaryHome(true, t)
defer cleanupTemporaryHome(tempHomeFolder, t)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{ // invalid test attribute should throw an error
ConfigVariables: testConfigProviderCredentials,
Config: providerInvalidAttribute,
ExpectError: regexp.MustCompile(`An argument named "test" is not expected here\.`),
},
},
})
}

View file

@ -0,0 +1,43 @@
# Copyright (c) STACKIT
variable "project_id" {}
variable "name" {}
provider "stackit" {
default_region = "eu01"
credentials_path = "~/.stackit/credentials.json"
service_account_token = ""
service_account_key_path = ""
service_account_key = ""
private_key_path = ""
private_key = ""
service_account_email = "abc@abc.de"
cdn_custom_endpoint = "https://cdn.api.eu01.stackit.cloud"
dns_custom_endpoint = "https://dns.api.stackit.cloud"
git_custom_endpoint = "https://git.api.stackit.cloud"
iaas_custom_endpoint = "https://iaas.api.stackit.cloud"
mongodbflex_custom_endpoint = "https://mongodbflex.api.stackit.cloud"
modelserving_custom_endpoint = "https://modelserving.api.stackit.cloud"
loadbalancer_custom_endpoint = "https://load-balancer.api.stackit.cloud"
mariadb_custom_endpoint = "https://mariadb.api.stackit.cloud"
authorization_custom_endpoint = "https://authorization.api.stackit.cloud"
objectstorage_custom_endpoint = "https://objectstorage.api.stackit.cloud"
observability_custom_endpoint = "https://observability.api.stackit.cloud"
opensearch_custom_endpoint = "https://opensearch.api.stackit.cloud"
postgresflex_custom_endpoint = "https://postgresflex.api.stackit.cloud"
redis_custom_endpoint = "https://redis.api.stackit.cloud"
server_backup_custom_endpoint = "https://server-backup.api.stackit.cloud"
server_update_custom_endpoint = "https://server-update.api.stackit.cloud"
service_account_custom_endpoint = "https://service-account.api.stackit.cloud"
resourcemanager_custom_endpoint = "https://resourcemanager.api.stackit.cloud"
sqlserverflex_custom_endpoint = "https://sqlserverflex.api.stackit.cloud"
ske_custom_endpoint = "https://ske.api.stackit.cloud"
service_enablement_custom_endpoint = "https://service-enablement.api.stackit.cloud"
token_custom_endpoint = "https://token.api.stackit.cloud"
enable_beta_resources = "true"
}
resource "stackit_network" "network" {
name = var.name
project_id = var.project_id
}

View file

@ -0,0 +1,12 @@
# Copyright (c) STACKIT
variable "project_id" {}
variable "name" {}
provider "stackit" {
}
resource "stackit_network" "network" {
name = var.name
project_id = var.project_id
}

View file

@ -0,0 +1,13 @@
# Copyright (c) STACKIT
variable "project_id" {}
variable "name" {}
provider "stackit" {
test = "test"
}
resource "stackit_network" "network" {
name = var.name
project_id = var.project_id
}