From 6851be1bb611bd6e238428500b55afe63b06b99d Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Mon, 3 Aug 2026 17:10:12 +0200 Subject: [PATCH 1/2] feat(postgresflex): implement flavors data source --- docs/data-sources/postgresflex_flavors.md | 84 ++++++ .../data-source.tf | 20 ++ .../postgresflex/flavors/datasource.go | 264 ++++++++++++++++++ .../postgresflex/flavors/datasource_test.go | 155 ++++++++++ .../postgresflex/postgresflex_acc_test.go | 35 +++ stackit/provider.go | 2 + 6 files changed, 560 insertions(+) create mode 100644 docs/data-sources/postgresflex_flavors.md create mode 100644 examples/data-sources/stackit_postgresflex_flavors/data-source.tf create mode 100644 stackit/internal/services/postgresflex/flavors/datasource.go create mode 100644 stackit/internal/services/postgresflex/flavors/datasource_test.go diff --git a/docs/data-sources/postgresflex_flavors.md b/docs/data-sources/postgresflex_flavors.md new file mode 100644 index 000000000..d2ef0533b --- /dev/null +++ b/docs/data-sources/postgresflex_flavors.md @@ -0,0 +1,84 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_postgresflex_flavors Data Source - stackit" +subcategory: "" +description: |- + Postgres Flex flavors data source schema. +--- + +# stackit_postgresflex_flavors (Data Source) + +Postgres Flex flavors data source schema. + +## Example Usage + +```terraform +data "stackit_postgresflex_flavors" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + # region is taken from the provider configuration +} + +# Example usage with an instance +resource "stackit_postgresflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + flavor_id = one([for flavor in data.stackit_postgresflex_flavors.example.flavors : flavor.id if flavor.cpu == 2 && flavor.memory == 4 && flavor.node_type == "Single"]) + backup_schedule = "0 16 * * *" + storage = { + class = "premium-perf2-stackit" + size = 5 + } + version = "17" + network = { + acl = ["192.168.0.0/24"] + } +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID. + +### Optional + +- `region` (String) Postgres Flex flavors data source region. If undefined, the provider region is used. +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + +### Read-Only + +- `flavors` (Attributes List) List of flavors available for the project. (see [below for nested schema](#nestedatt--flavors)) +- `id` (String) Terraform's internal data source ID, structured as "`project_id`,`region`". + + +### Nested Schema for `timeouts` + +Optional: + +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). + + + +### Nested Schema for `flavors` + +Read-Only: + +- `cpu` (Number) CPU count of the instance. +- `description` (String) Flavor description. +- `id` (String) Flavor ID. +- `max_gb` (Number) Maximum storage capacity available for the flavor in GB. +- `memory` (Number) Memory of the instance in GiB. +- `min_gb` (Number) Minimum storage capacity available for the flavor in GB. +- `node_type` (String) Node type of the flavor, either single or replica. +- `storage_classes` (Attributes List) Storage classes available for the flavor. (see [below for nested schema](#nestedatt--flavors--storage_classes)) + + +### Nested Schema for `flavors.storage_classes` + +Read-Only: + +- `class` (String) Storage class. +- `max_io_per_sec` (Number) Maximum I/O operations per second. +- `max_through_in_mb` (Number) Maximum throughput in MB per second. diff --git a/examples/data-sources/stackit_postgresflex_flavors/data-source.tf b/examples/data-sources/stackit_postgresflex_flavors/data-source.tf new file mode 100644 index 000000000..20787602f --- /dev/null +++ b/examples/data-sources/stackit_postgresflex_flavors/data-source.tf @@ -0,0 +1,20 @@ +data "stackit_postgresflex_flavors" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + # region is taken from the provider configuration +} + +# Example usage with an instance +resource "stackit_postgresflex_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + flavor_id = one([for flavor in data.stackit_postgresflex_flavors.example.flavors : flavor.id if flavor.cpu == 2 && flavor.memory == 4 && flavor.node_type == "Single"]) + backup_schedule = "0 16 * * *" + storage = { + class = "premium-perf2-stackit" + size = 5 + } + version = "17" + network = { + acl = ["192.168.0.0/24"] + } +} diff --git a/stackit/internal/services/postgresflex/flavors/datasource.go b/stackit/internal/services/postgresflex/flavors/datasource.go new file mode 100644 index 000000000..89ba99a3a --- /dev/null +++ b/stackit/internal/services/postgresflex/flavors/datasource.go @@ -0,0 +1,264 @@ +package flavors + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = new(flavors) + _ datasource.DataSourceWithConfigure = new(flavors) +) + +type model struct { + ID types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + Flavors []flavor `tfsdk:"flavors"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +type flavor struct { + Id types.String `tfsdk:"id"` + Description types.String `tfsdk:"description"` + CPU types.Int64 `tfsdk:"cpu"` + Memory types.Int64 `tfsdk:"memory"` + MinGB types.Int32 `tfsdk:"min_gb"` + MaxGB types.Int32 `tfsdk:"max_gb"` + NodeType types.String `tfsdk:"node_type"` + StorageClasses []storageClass `tfsdk:"storage_classes"` +} + +type storageClass struct { + Class types.String `tfsdk:"class"` + MaxIOPerSec types.Int32 `tfsdk:"max_io_per_sec"` + MaxThroughInMB types.Int32 `tfsdk:"max_through_in_mb"` +} + +type flavors struct { + client *postgresflex.APIClient + providerData core.ProviderData +} + +func NewFlavorsDataSource() datasource.DataSource { + return new(flavors) +} + +func (f *flavors) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_postgresflex_flavors" +} + +func (f *flavors) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + f.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := postgresflexUtils.ConfigureClient(ctx, &f.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + f.client = apiClient + tflog.Info(ctx, "Postgres Flex flavors client configured") +} + +func (f *flavors) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Postgres Flex flavors data source schema.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source ID, structured as \"`project_id`,`region`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "Postgres Flex flavors data source region. If undefined, the provider region is used.", + Optional: true, + Computed: true, + }, + "timeouts": timeouts.Attributes(ctx), + "flavors": schema.ListNestedAttribute{ + Description: "List of flavors available for the project.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Flavor ID.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Flavor description.", + Computed: true, + }, + "cpu": schema.Int64Attribute{ + Description: "CPU count of the instance.", + Computed: true, + }, + "memory": schema.Int64Attribute{ + Description: "Memory of the instance in GiB.", + Computed: true, + }, + "min_gb": schema.Int32Attribute{ + Description: "Minimum storage capacity available for the flavor in GB.", + Computed: true, + }, + "max_gb": schema.Int32Attribute{ + Description: "Maximum storage capacity available for the flavor in GB.", + Computed: true, + }, + "node_type": schema.StringAttribute{ + Description: "Node type of the flavor, either single or replica.", + Computed: true, + }, + "storage_classes": schema.ListNestedAttribute{ + Description: "Storage classes available for the flavor.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "class": schema.StringAttribute{ + Description: "Storage class.", + Computed: true, + }, + "max_io_per_sec": schema.Int32Attribute{ + Description: "Maximum I/O operations per second.", + Computed: true, + }, + "max_through_in_mb": schema.Int32Attribute{ + Description: "Maximum throughput in MB per second.", + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func (f *flavors) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model model + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + readTimeout, diags := model.Timeouts.Read(ctx, core.DefaultOperationTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, readTimeout) + defer cancel() + + projectId := model.ProjectId.ValueString() + region := f.providerData.GetRegionWithOverride(model.Region) + model.Region = types.StringValue(region) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + }) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + const pageSize int64 = 100 + flavorsResp, err := f.client.DefaultAPI.ListFlavors(ctx, projectId, region).Size(pageSize).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Reading flavors", fmt.Sprintf("Calling ListFlavors: %v", err)) + return + } + if flavorsResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Reading flavors", "ListFlavors returned an empty response") + return + } + if flavorsResp.Pagination.TotalRows > pageSize { + core.LogAndAddWarning(ctx, &resp.Diagnostics, + "Truncated results", + fmt.Sprintf("Due to API limitations, only the first %d of %d available flavors are returned.", pageSize, flavorsResp.Pagination.TotalRows), + ) + } + + ctx = core.LogResponse(ctx) + + if err := mapFields(flavorsResp, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Reading flavors", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Postgres Flex flavors read") +} + +func mapFields(resp *postgresflex.ListFlavorsResponse, m *model) error { + if resp == nil { + return fmt.Errorf("nil response") + } + if m == nil { + return fmt.Errorf("nil model") + } + + m.ID = utils.BuildInternalTerraformId(m.ProjectId.ValueString(), m.Region.ValueString()) + m.Flavors = make([]flavor, 0, len(resp.Flavors)) + + slices.SortFunc(resp.Flavors, func(a, b postgresflex.ListFlavors) int { + return strings.Compare(a.Id, b.Id) + }) + + for _, respFlavor := range resp.Flavors { + modelFlavor := flavor{ + Id: types.StringValue(respFlavor.Id), + Description: types.StringValue(respFlavor.Description), + CPU: types.Int64Value(respFlavor.Cpu), + Memory: types.Int64Value(respFlavor.Memory), + MinGB: types.Int32Value(respFlavor.MinGB), + MaxGB: types.Int32Value(respFlavor.MaxGB), + NodeType: types.StringValue(respFlavor.NodeType), + } + + slices.SortFunc(respFlavor.StorageClasses, func(a, b postgresflex.FlavorStorageClassesStorageClass) int { + return strings.Compare(a.Class, b.Class) + }) + + modelFlavor.StorageClasses = make([]storageClass, 0, len(respFlavor.StorageClasses)) + for _, respStorageClass := range respFlavor.StorageClasses { + modelFlavor.StorageClasses = append(modelFlavor.StorageClasses, storageClass{ + Class: types.StringValue(respStorageClass.Class), + MaxIOPerSec: types.Int32Value(respStorageClass.MaxIoPerSec), + MaxThroughInMB: types.Int32Value(respStorageClass.MaxThroughInMb), + }) + } + m.Flavors = append(m.Flavors, modelFlavor) + } + return nil +} diff --git a/stackit/internal/services/postgresflex/flavors/datasource_test.go b/stackit/internal/services/postgresflex/flavors/datasource_test.go new file mode 100644 index 000000000..55dbab17b --- /dev/null +++ b/stackit/internal/services/postgresflex/flavors/datasource_test.go @@ -0,0 +1,155 @@ +package flavors + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + name string + response *postgresflex.ListFlavorsResponse + model *model + expected *model + valid bool + }{ + { + name: "maps and sorts flavors and storage classes", + response: &postgresflex.ListFlavorsResponse{ + Flavors: []postgresflex.ListFlavors{ + { + Id: "flavor-2", + Description: "second", + Cpu: 4, + Memory: 8, + MinGB: 20, + MaxGB: 200, + NodeType: "Replica", + StorageClasses: []postgresflex.FlavorStorageClassesStorageClass{ + { + Class: "class-2", + MaxIoPerSec: 2000, + MaxThroughInMb: 200, + }, + { + Class: "class-1", + MaxIoPerSec: 1000, + MaxThroughInMb: 100, + }, + }, + }, + { + Id: "flavor-1", + Description: "first", + Cpu: 2, + Memory: 4, + MinGB: 10, + MaxGB: 100, + NodeType: "Single", + StorageClasses: []postgresflex.FlavorStorageClassesStorageClass{ + { + Class: "class-3", + MaxIoPerSec: 3000, + MaxThroughInMb: 300, + }, + }, + }, + }, + }, + model: &model{ + ProjectId: types.StringValue("project-id"), + Region: types.StringValue("eu01"), + }, + expected: &model{ + ID: types.StringValue("project-id,eu01"), + ProjectId: types.StringValue("project-id"), + Region: types.StringValue("eu01"), + Flavors: []flavor{ + { + Id: types.StringValue("flavor-1"), + Description: types.StringValue("first"), + CPU: types.Int64Value(2), + Memory: types.Int64Value(4), + MinGB: types.Int32Value(10), + MaxGB: types.Int32Value(100), + NodeType: types.StringValue("Single"), + StorageClasses: []storageClass{ + { + Class: types.StringValue("class-3"), + MaxIOPerSec: types.Int32Value(3000), + MaxThroughInMB: types.Int32Value(300), + }, + }, + }, + { + Id: types.StringValue("flavor-2"), + Description: types.StringValue("second"), + CPU: types.Int64Value(4), + Memory: types.Int64Value(8), + MinGB: types.Int32Value(20), + MaxGB: types.Int32Value(200), + NodeType: types.StringValue("Replica"), + StorageClasses: []storageClass{ + { + Class: types.StringValue("class-1"), + MaxIOPerSec: types.Int32Value(1000), + MaxThroughInMB: types.Int32Value(100), + }, + { + Class: types.StringValue("class-2"), + MaxIOPerSec: types.Int32Value(2000), + MaxThroughInMB: types.Int32Value(200), + }, + }, + }, + }, + }, + valid: true, + }, + { + name: "maps empty response", + response: &postgresflex.ListFlavorsResponse{}, + model: &model{ + ProjectId: types.StringValue("project-id"), + Region: types.StringValue("eu01"), + }, + expected: &model{ + ID: types.StringValue("project-id,eu01"), + ProjectId: types.StringValue("project-id"), + Region: types.StringValue("eu01"), + Flavors: []flavor{}, + }, + valid: true, + }, + { + name: "rejects nil response", + model: &model{ + ProjectId: types.StringValue("project-id"), + Region: types.StringValue("eu01"), + }, + valid: false, + }, + { + name: "rejects nil model", + response: &postgresflex.ListFlavorsResponse{}, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mapFields(tt.response, tt.model) + if (err == nil) != tt.valid { + t.Fatalf("mapFields() error = %v, valid = %t", err, tt.valid) + } + if tt.valid { + if diff := cmp.Diff(tt.expected, tt.model); diff != "" { + t.Fatalf("mapFields() mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/postgresflex/postgresflex_acc_test.go b/stackit/internal/services/postgresflex/postgresflex_acc_test.go index 7bbd9a7bf..a5922ec4b 100644 --- a/stackit/internal/services/postgresflex/postgresflex_acc_test.go +++ b/stackit/internal/services/postgresflex/postgresflex_acc_test.go @@ -868,6 +868,41 @@ func TestAccPostgresFlexUserMin(t *testing.T) { }) } +func TestAccPostgresFlexFlavorsDataSource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + }, + Config: fmt.Sprintf(` + %s + + variable project_id {} + + data "stackit_postgresflex_flavors" "datasource" { + project_id = var.project_id + }`, + testutil.NewConfigBuilder().BuildProviderConfig(), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.description"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.cpu"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.memory"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.min_gb"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.max_gb"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.node_type"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.storage_classes.0.class"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.storage_classes.0.max_io_per_sec"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_flavors.datasource", "flavors.0.storage_classes.0.max_through_in_mb"), + ), + }, + }, + }) +} + func testCheckDestroy(s *terraform.State) error { checkDestroyFuncs := []resource.TestCheckFunc{ testDatabaseDestroy, diff --git a/stackit/provider.go b/stackit/provider.go index b89bb0895..5c99aaa50 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -95,6 +95,7 @@ import ( openSearchCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/opensearch/credential" openSearchInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/opensearch/instance" postgresFlexDatabase "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/database" + postgresFlexFlavors "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/flavors" postgresFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/instance" postgresFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/user" rabbitMQCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/rabbitmq/credential" @@ -724,6 +725,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource openSearchInstance.NewInstanceDataSource, openSearchCredential.NewCredentialDataSource, postgresFlexDatabase.NewDatabaseDataSource, + postgresFlexFlavors.NewFlavorsDataSource, postgresFlexInstance.NewInstanceDataSource, postgresFlexUser.NewUserDataSource, rabbitMQInstance.NewInstanceDataSource, From ea416d107134f60894e49a38ceeda28a1c1cf840 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Mon, 3 Aug 2026 17:13:04 +0200 Subject: [PATCH 2/2] generate docs --- docs/data-sources/postgresflex_flavors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/postgresflex_flavors.md b/docs/data-sources/postgresflex_flavors.md index d2ef0533b..931144aa8 100644 --- a/docs/data-sources/postgresflex_flavors.md +++ b/docs/data-sources/postgresflex_flavors.md @@ -20,8 +20,8 @@ data "stackit_postgresflex_flavors" "example" { # Example usage with an instance resource "stackit_postgresflex_instance" "example" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - name = "example" + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" flavor_id = one([for flavor in data.stackit_postgresflex_flavors.example.flavors : flavor.id if flavor.cpu == 2 && flavor.memory == 4 && flavor.node_type == "Single"]) backup_schedule = "0 16 * * *" storage = {