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

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

View file

@ -10,7 +10,8 @@ import (
"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"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
type attributeGetter interface {

View file

@ -0,0 +1,229 @@
package utils
import (
"fmt"
"log/slog"
"reflect"
"slices"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/iancoleman/strcase"
"gopkg.in/yaml.v3"
)
type Field struct {
Name string `yaml:"name"`
Modifiers []*string `yaml:"modifiers"`
}
type Fields struct {
Fields []*Field `yaml:"fields"`
}
var validModifiers = []string{
"UseStateForUnknown",
"RequiresReplace",
}
func ReadModifiersConfig(content []byte) (*Fields, error) {
var fields Fields
err := yaml.Unmarshal(content, &fields)
if err != nil {
return nil, err
}
return &fields, nil
}
func AddPlanModifiersToResourceSchema(fields *Fields, s *schema.Schema) error {
err := validateFields(fields)
if err != nil {
return err
}
resAttr, err := handleAttributes("", s.Attributes, fields)
if err != nil {
return err
}
s.Attributes = resAttr
return nil
}
func handleAttributes(prefix string, attributes map[string]schema.Attribute, fields *Fields) (map[string]schema.Attribute, error) {
fieldMap := fieldListToMap(fields)
for attrName, attrValue := range attributes {
attrNameSnake := strcase.ToSnake(attrName)
if prefix != "" {
attrNameSnake = prefix + "." + attrNameSnake
}
switch reflect.TypeOf(attrValue).String() {
case "schema.BoolAttribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleBoolPlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.Int64Attribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleInt64PlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.StringAttribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleStringPlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.ListAttribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleListPlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.SingleNestedAttribute":
nested, ok := attrValue.(schema.SingleNestedAttribute)
if !ok {
if _, ok := attrValue.(interface {
GetAttributes() map[string]schema.Attribute
}); ok {
return nil, fmt.Errorf("unsupported type for single nested attribute")
}
}
res, err := handleAttributes(attrName, nested.Attributes, fields)
if err != nil {
return nil, err
}
nested.Attributes = res
attributes[attrName] = nested
default:
slog.Warn("type currently not supported", "type", reflect.TypeOf(attrValue).String())
}
}
return attributes, nil
}
func handleBoolPlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.BoolAttribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, boolplanmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, boolplanmodifier.UseStateForUnknown())
}
}
return a, nil
}
func handleStringPlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.StringAttribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, stringplanmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, stringplanmodifier.UseStateForUnknown())
}
}
return a, nil
}
func handleInt64PlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.Int64Attribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, int64planmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, int64planmodifier.UseStateForUnknown())
}
}
return a, nil
}
func handleListPlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.ListAttribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, listplanmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, listplanmodifier.UseStateForUnknown())
}
}
return a, nil
}
func validateFields(fields *Fields) error {
if fields == nil {
return nil
}
for _, field := range fields.Fields {
for _, modifier := range field.Modifiers {
if *modifier == "" {
return fmt.Errorf("modifier %s is required", *modifier)
}
if !slices.Contains(validModifiers, *modifier) {
return fmt.Errorf("modifier %s is invalid", *modifier)
}
}
}
return nil
}
func fieldListToMap(fields *Fields) map[string][]*string {
res := make(map[string][]*string)
if fields != nil {
for _, field := range fields.Fields {
res[field.Name] = field.Modifiers
}
} else {
slog.Warn("no fields available")
}
return res
}

View file

@ -0,0 +1,224 @@
package utils
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
)
func TestReadModifiersConfig(t *testing.T) {
testcases := []struct {
name string
content []byte
wantErr bool
}{
{
name: "valid yaml",
content: []byte(`
fields:
- name: 'id'
modifiers:
- 'UseStateForUnknown'
`),
wantErr: false,
},
{
name: "invalid yaml",
content: []byte(`invalid: yaml: :`),
wantErr: true,
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
_, err := ReadModifiersConfig(tc.content)
if (err != nil) != tc.wantErr {
t.Errorf("ReadModifiersConfig() error = %v, wantErr %v", err, tc.wantErr)
}
},
)
}
}
func TestAddPlanModifiersToResourceSchema(t *testing.T) {
testcases := []struct {
name string
fields *Fields
sch *schema.Schema
wantErr bool
}{
{
name: "full coverage - all types and nested structures",
fields: &Fields{
Fields: []*Field{
{
Name: "string_attr",
Modifiers: []*string{utils.Ptr("RequiresReplace"), utils.Ptr("UseStateForUnknown")},
},
{Name: "bool_attr", Modifiers: []*string{utils.Ptr("RequiresReplace")}},
{Name: "int_attr", Modifiers: []*string{utils.Ptr("UseStateForUnknown")}},
{Name: "list_attr", Modifiers: []*string{utils.Ptr("RequiresReplace")}},
{Name: "Nested.sub_string", Modifiers: []*string{utils.Ptr("RequiresReplace")}},
},
},
sch: &schema.Schema{
Attributes: map[string]schema.Attribute{
"StringAttr": schema.StringAttribute{},
"BoolAttr": schema.BoolAttribute{},
"IntAttr": schema.Int64Attribute{},
"ListAttr": schema.ListAttribute{},
"Nested": schema.SingleNestedAttribute{
Attributes: map[string]schema.Attribute{
"SubString": schema.StringAttribute{},
},
},
"Unsupported": schema.MapAttribute{ElementType: types.StringType}, // Triggers default/warn case
},
},
wantErr: false,
},
{
name: "validation error - invalid modifier",
fields: &Fields{
Fields: []*Field{
{Name: "id", Modifiers: []*string{utils.Ptr("InvalidModifier")}},
},
},
sch: &schema.Schema{
Attributes: map[string]schema.Attribute{"id": schema.StringAttribute{}},
},
wantErr: true,
},
{
name: "validation error - empty modifier",
fields: &Fields{
Fields: []*Field{
{Name: "id", Modifiers: []*string{utils.Ptr("")}},
},
},
sch: &schema.Schema{},
wantErr: true,
},
{
name: "nil fields - should return nil",
fields: nil,
sch: &schema.Schema{},
wantErr: false,
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
err := AddPlanModifiersToResourceSchema(tc.fields, tc.sch)
if (err != nil) != tc.wantErr {
t.Fatalf("AddPlanModifiersToResourceSchema() error = %v, wantErr %v", err, tc.wantErr)
}
if !tc.wantErr && tc.name == "full coverage - all types and nested structures" {
// Check StringAttr
if sAttr, ok := tc.sch.Attributes["StringAttr"].(schema.StringAttribute); ok {
if len(sAttr.PlanModifiers) != 2 {
t.Errorf("StringAttr: expected 2 modifiers, got %d", len(sAttr.PlanModifiers))
}
}
// Check Nested Sub-Attribute
if nested, ok := tc.sch.Attributes["Nested"].(schema.SingleNestedAttribute); ok {
if subAttr, ok := nested.Attributes["SubString"].(schema.StringAttribute); ok {
if len(subAttr.PlanModifiers) != 1 {
// Dies schlug vorher fehl, weil der Prefix "Nested" statt "nested" war
t.Errorf("Nested SubString: expected 1 modifier, got %d", len(subAttr.PlanModifiers))
}
} else {
t.Error("SubString attribute not found in Nested")
}
} else {
t.Error("Nested attribute not found")
}
}
},
)
}
}
func TestFieldListToMap(t *testing.T) {
testcases := []struct {
name string
fields *Fields
want map[string][]*string
}{
{
name: "convert list to map",
fields: &Fields{
Fields: []*Field{
{Name: "test", Modifiers: []*string{utils.Ptr("mod")}},
},
},
want: map[string][]*string{
"test": {utils.Ptr("mod")},
},
},
{
name: "nil fields",
fields: nil,
want: map[string][]*string{},
},
}
for _, tc := range testcases {
t.Run(
tc.name, func(t *testing.T) {
got := fieldListToMap(tc.fields)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("fieldListToMap() mismatch (-want +got):\n%s", diff)
}
},
)
}
}
func TestHandleTypeMismatches(t *testing.T) {
modifiers := []*string{utils.Ptr("RequiresReplace")}
t.Run(
"bool type mismatch", func(t *testing.T) {
_, err := handleBoolPlanModifiers(schema.StringAttribute{}, modifiers)
if err == nil {
t.Error("expected error for type mismatch in handleBoolPlanModifiers")
}
},
)
t.Run(
"string type mismatch", func(t *testing.T) {
_, err := handleStringPlanModifiers(schema.BoolAttribute{}, modifiers)
if err == nil {
t.Error("expected error for type mismatch in handleStringPlanModifiers")
}
},
)
t.Run(
"int64 type mismatch", func(t *testing.T) {
_, err := handleInt64PlanModifiers(schema.StringAttribute{}, modifiers)
if err == nil {
t.Error("expected error for type mismatch in handleInt64PlanModifiers")
}
},
)
t.Run(
"list type mismatch", func(t *testing.T) {
_, err := handleListPlanModifiers(schema.StringAttribute{}, modifiers)
if err == nil {
t.Error("expected error for type mismatch in handleListPlanModifiers")
}
},
)
}

View file

@ -8,7 +8,8 @@ import (
"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"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
// AdaptRegion rewrites the region of a terraform plan

View file

@ -0,0 +1,12 @@
package utils
func RemoveQuotes(src string) string {
var res string
if src != "" && src[0] == '"' {
res = src[1:]
}
if res != "" && res[len(res)-1] == '"' {
res = res[:len(res)-1]
}
return res
}

View file

@ -20,7 +20,8 @@ import (
"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"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
)
const (