diff --git a/docs/data-sources/ske_kubernetes_versions.md b/docs/data-sources/ske_kubernetes_versions.md new file mode 100644 index 000000000..c64bab2aa --- /dev/null +++ b/docs/data-sources/ske_kubernetes_versions.md @@ -0,0 +1,60 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_ske_kubernetes_versions Data Source - stackit" +subcategory: "" +description: |- + Returns Kubernetes versions as reported by the SKE provider options API for the given region. +--- + +# stackit_ske_kubernetes_versions (Data Source) + +Returns Kubernetes versions as reported by the SKE provider options API for the given region. + +## Example Usage + +```terraform +data "stackit_ske_kubernetes_versions" "example" { + version_state = "SUPPORTED" +} + +resource "stackit_ske_cluster" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + kubernetes_version = data.stackit_ske_kubernetes_versions.example.kubernetes_versions.0.version + node_pools = [ + { + name = "np-example" + machine_type = "x.x" + os_version = "x.x.x" + os_name = "xxx" + minimum = "2" + maximum = "3" + availability_zones = ["eu01-1"] + volume_type = "storage_premium_perf6" + volume_size = "48" + } + ] +} +``` + + +## Schema + +### Optional + +- `region` (String) Region override. If omitted, the provider’s region will be used. +- `version_state` (String) If specified, only returns Kubernetes versions with this version state. Possible values are: `UNSPECIFIED`, `SUPPORTED`. + +### Read-Only + +- `kubernetes_versions` (Attributes List) Kubernetes versions and their metadata. (see [below for nested schema](#nestedatt--kubernetes_versions)) + + +### Nested Schema for `kubernetes_versions` + +Read-Only: + +- `expiration_date` (String) Expiration date of the version in RFC3339 format. +- `feature_gates` (Map of String) Map of available feature gates for this version. +- `state` (String) State of the kubernetes version. +- `version` (String) Kubernetes version string (e.g., `1.33.6`). diff --git a/docs/data-sources/ske_machine_image_versions.md b/docs/data-sources/ske_machine_image_versions.md new file mode 100644 index 000000000..eaab52642 --- /dev/null +++ b/docs/data-sources/ske_machine_image_versions.md @@ -0,0 +1,78 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_ske_machine_image_versions Data Source - stackit" +subcategory: "" +description: |- + Returns a list of supported Kubernetes machine image versions for the cluster nodes. +--- + +# stackit_ske_machine_image_versions (Data Source) + +Returns a list of supported Kubernetes machine image versions for the cluster nodes. + +## Example Usage + +```terraform +data "stackit_ske_machine_image_versions" "example" { + version_state = "SUPPORTED" +} + +locals { + flatcar_supported_version = one(flatten([ + for mi in data.stackit_ske_machine_image_versions.example.machine_images : [ + for v in mi.versions : + v.version + if mi.name == "flatcar" # or ubuntu + ] + ])) +} + +resource "stackit_ske_cluster" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + kubernetes_version = "x.x" + node_pools = [ + { + name = "np-example" + machine_type = "x.x" + os_version = local.flatcar_supported_version + os_name = "flatcar" + minimum = "2" + maximum = "3" + availability_zones = ["eu01-1"] + volume_type = "storage_premium_perf6" + volume_size = "48" + } + ] +} +``` + + +## Schema + +### Optional + +- `region` (String) Region override. If omitted, the provider’s region will be used. +- `version_state` (String) Filter returned machine image versions by their state. Possible values are: `UNSPECIFIED`, `SUPPORTED`. + +### Read-Only + +- `machine_images` (Attributes List) Supported machine image types and versions. (see [below for nested schema](#nestedatt--machine_images)) + + +### Nested Schema for `machine_images` + +Read-Only: + +- `name` (String) Name of the OS image (e.g., `ubuntu` or `flatcar`). +- `versions` (Attributes List) Supported versions of the image. (see [below for nested schema](#nestedatt--machine_images--versions)) + + +### Nested Schema for `machine_images.versions` + +Read-Only: + +- `cri` (List of String) Container runtimes supported (e.g., `containerd`). +- `expiration_date` (String) Expiration date of the version in RFC3339 format. +- `state` (String) State of the image version. +- `version` (String) Machine image version string. diff --git a/docs/resources/ske_cluster.md b/docs/resources/ske_cluster.md index b8b681405..44616c9be 100644 --- a/docs/resources/ske_cluster.md +++ b/docs/resources/ske_cluster.md @@ -25,9 +25,12 @@ resource "stackit_ske_cluster" "example" { name = "np-example" machine_type = "x.x" os_version = "x.x.x" + os_name = "xxx" minimum = "2" maximum = "3" availability_zones = ["eu01-3"] + volume_type = "storage_premium_perf6" + volume_size = "48" } ] maintenance = { diff --git a/examples/data-sources/stackit_ske_kubernetes_versions/data-source.tf b/examples/data-sources/stackit_ske_kubernetes_versions/data-source.tf new file mode 100644 index 000000000..28d306e99 --- /dev/null +++ b/examples/data-sources/stackit_ske_kubernetes_versions/data-source.tf @@ -0,0 +1,22 @@ +data "stackit_ske_kubernetes_versions" "example" { + version_state = "SUPPORTED" +} + +resource "stackit_ske_cluster" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + kubernetes_version = data.stackit_ske_kubernetes_versions.example.kubernetes_versions.0.version + node_pools = [ + { + name = "np-example" + machine_type = "x.x" + os_version = "x.x.x" + os_name = "xxx" + minimum = "2" + maximum = "3" + availability_zones = ["eu01-1"] + volume_type = "storage_premium_perf6" + volume_size = "48" + } + ] +} \ No newline at end of file diff --git a/examples/data-sources/stackit_ske_machine_image_versions/data-source.tf b/examples/data-sources/stackit_ske_machine_image_versions/data-source.tf new file mode 100644 index 000000000..c4238496d --- /dev/null +++ b/examples/data-sources/stackit_ske_machine_image_versions/data-source.tf @@ -0,0 +1,32 @@ +data "stackit_ske_machine_image_versions" "example" { + version_state = "SUPPORTED" +} + +locals { + flatcar_supported_version = one(flatten([ + for mi in data.stackit_ske_machine_image_versions.example.machine_images : [ + for v in mi.versions : + v.version + if mi.name == "flatcar" # or ubuntu + ] + ])) +} + +resource "stackit_ske_cluster" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example" + kubernetes_version = "x.x" + node_pools = [ + { + name = "np-example" + machine_type = "x.x" + os_version = local.flatcar_supported_version + os_name = "flatcar" + minimum = "2" + maximum = "3" + availability_zones = ["eu01-1"] + volume_type = "storage_premium_perf6" + volume_size = "48" + } + ] +} \ No newline at end of file diff --git a/examples/resources/stackit_ske_cluster/resource.tf b/examples/resources/stackit_ske_cluster/resource.tf index cabf801ac..e87958fd2 100644 --- a/examples/resources/stackit_ske_cluster/resource.tf +++ b/examples/resources/stackit_ske_cluster/resource.tf @@ -7,9 +7,12 @@ resource "stackit_ske_cluster" "example" { name = "np-example" machine_type = "x.x" os_version = "x.x.x" + os_name = "xxx" minimum = "2" maximum = "3" availability_zones = ["eu01-3"] + volume_type = "storage_premium_perf6" + volume_size = "48" } ] maintenance = { diff --git a/stackit/internal/services/ske/provideroptions/kubernetesversions/datasource.go b/stackit/internal/services/ske/provideroptions/kubernetesversions/datasource.go new file mode 100644 index 000000000..a4ea5a848 --- /dev/null +++ b/stackit/internal/services/ske/provideroptions/kubernetesversions/datasource.go @@ -0,0 +1,239 @@ +package kubernetesversions + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + skeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +// Model types +type Model struct { + Region types.String `tfsdk:"region"` + VersionState types.String `tfsdk:"version_state"` + KubernetesVersions types.List `tfsdk:"kubernetes_versions"` +} + +var ( + kubernetesVersionType = map[string]attr.Type{ + "version": types.StringType, + "expiration_date": types.StringType, + "feature_gates": types.MapType{ElemType: types.StringType}, + "state": types.StringType, + } + + versionStateOptions = []string{"UNSPECIFIED", "SUPPORTED"} +) + +// Ensure implementation satisfies interface +var _ datasource.DataSource = &kubernetesVersionsDataSource{} + +// NewKubernetesVersionsDataSource creates the data source instance +func NewKubernetesVersionsDataSource() datasource.DataSource { + return &kubernetesVersionsDataSource{} +} + +type kubernetesVersionsDataSource struct { + client *ske.APIClient + providerData core.ProviderData +} + +// Metadata sets the data source type name +func (d *kubernetesVersionsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ske_kubernetes_versions" +} + +func (d *kubernetesVersionsDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + d.client = skeUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "SKE options client configured") +} + +func (d *kubernetesVersionsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + description := "Returns Kubernetes versions as reported by the SKE provider options API for the given region." + + resp.Schema = schema.Schema{ + Description: description, + Attributes: map[string]schema.Attribute{ + "region": schema.StringAttribute{ + Optional: true, + Description: "Region override. If omitted, the provider’s region will be used.", + }, + "version_state": schema.StringAttribute{ + Optional: true, + Description: "If specified, only returns Kubernetes versions with this version state. " + utils.FormatPossibleValues(versionStateOptions...), + Validators: []validator.String{ + stringvalidator.OneOf(versionStateOptions...), + }, + }, + "kubernetes_versions": schema.ListNestedAttribute{ + Computed: true, + Description: "Kubernetes versions and their metadata.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "version": schema.StringAttribute{ + Computed: true, + Description: "Kubernetes version string (e.g., `1.33.6`).", + }, + "expiration_date": schema.StringAttribute{ + Computed: true, + Description: "Expiration date of the version in RFC3339 format.", + }, + "state": schema.StringAttribute{ + Computed: true, + Description: "State of the kubernetes version.", + }, + "feature_gates": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + Description: "Map of available feature gates for this version.", + }, + }, + }, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *kubernetesVersionsDataSource) 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 + } + + region := d.providerData.GetRegionWithOverride(model.Region) + + ctx = core.InitProviderContext(ctx) + ctx = tflog.SetField(ctx, "region", region) + + listProviderOptionsReq := d.client.ListProviderOptions(ctx, region) + + if !utils.IsUndefined(model.VersionState) { + listProviderOptionsReq = listProviderOptionsReq.VersionState(model.VersionState.ValueString()) + } + + optionsResp, err := listProviderOptionsReq.Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading SKE provider options failed", + "Unable to read SKE provider options", + map[int]string{ + http.StatusForbidden: "Forbidden access", + }, + ) + return + } + + if err := mapFields(ctx, optionsResp, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading provider options", fmt.Sprintf("Mapping API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Read SKE provider options successfully", map[string]interface{}{ + "region": region, + "versionState": model.VersionState.ValueString(), + }) +} + +func mapFields(_ context.Context, optionsResp *ske.ProviderOptions, model *Model) error { + if optionsResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + if optionsResp.KubernetesVersions == nil { + emptyList, diags := types.ListValue( + types.ObjectType{AttrTypes: kubernetesVersionType}, + []attr.Value{}, + ) + if diags.HasError() { + return core.DiagsToError(diags) + } + model.KubernetesVersions = emptyList + return nil + } + + kvSlice := *optionsResp.KubernetesVersions + kvList := make([]attr.Value, 0, len(kvSlice)) + + for _, kv := range kvSlice { + expDate := types.StringNull() + if kv.ExpirationDate != nil { + expDate = types.StringValue(kv.ExpirationDate.Format(time.RFC3339)) + } + + featureGateValues := map[string]attr.Value{} + if kv.FeatureGates != nil { + for k, v := range *kv.FeatureGates { + featureGateValues[k] = types.StringValue(v) + } + } + + featureGatesMap, diags := types.MapValue(types.StringType, featureGateValues) + if diags.HasError() { + return core.DiagsToError(diags) + } + + obj, diags := types.ObjectValue( + kubernetesVersionType, + map[string]attr.Value{ + "version": types.StringPointerValue(kv.Version), + "state": types.StringPointerValue(kv.State), + "expiration_date": expDate, + "feature_gates": featureGatesMap, + }, + ) + if diags.HasError() { + return core.DiagsToError(diags) + } + + kvList = append(kvList, obj) + } + + kvs, diags := types.ListValue( + types.ObjectType{AttrTypes: kubernetesVersionType}, + kvList, + ) + if diags.HasError() { + return core.DiagsToError(diags) + } + model.KubernetesVersions = kvs + + return nil +} diff --git a/stackit/internal/services/ske/provideroptions/kubernetesversions/datasource_test.go b/stackit/internal/services/ske/provideroptions/kubernetesversions/datasource_test.go new file mode 100644 index 000000000..bf7d8a482 --- /dev/null +++ b/stackit/internal/services/ske/provideroptions/kubernetesversions/datasource_test.go @@ -0,0 +1,304 @@ +package kubernetesversions + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + skeutils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +func TestMapFields(t *testing.T) { + expDeprecated1 := time.Date(2026, 1, 28, 8, 0, 0, 0, time.UTC) + expDeprecated2 := time.Date(2026, 1, 14, 8, 0, 0, 0, time.UTC) + + expDeprecated1Str := expDeprecated1.Format(time.RFC3339) + expDeprecated2Str := expDeprecated2.Format(time.RFC3339) + + tests := []struct { + name string + input *ske.ProviderOptions + model *Model + expected *Model + isValid bool + }{ + { + name: "nil input provider options", + input: nil, + model: &Model{}, + expected: &Model{}, // not used, we expect an error + isValid: false, + }, + { + name: "multiple versions realistic payload", + input: &ske.ProviderOptions{ + KubernetesVersions: &[]ske.KubernetesVersion{ + { + Version: skeutils.Ptr("1.31.14"), + State: skeutils.Ptr("deprecated"), + ExpirationDate: &expDeprecated1, + FeatureGates: &map[string]string{}, + }, + { + Version: skeutils.Ptr("1.32.10"), + State: skeutils.Ptr("deprecated"), + ExpirationDate: &expDeprecated2, + FeatureGates: &map[string]string{}, + }, + { + Version: skeutils.Ptr("1.33.6"), + State: skeutils.Ptr("deprecated"), + ExpirationDate: &expDeprecated2, + FeatureGates: &map[string]string{}, + }, + { + Version: skeutils.Ptr("1.34.2"), + State: skeutils.Ptr("deprecated"), + ExpirationDate: &expDeprecated2, + FeatureGates: &map[string]string{}, + }, + { + Version: skeutils.Ptr("1.32.11"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + FeatureGates: &map[string]string{}, + }, + { + Version: skeutils.Ptr("1.33.7"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + FeatureGates: &map[string]string{}, + }, + { + Version: skeutils.Ptr("1.34.3"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + FeatureGates: &map[string]string{}, + }, + }, + }, + model: &Model{}, + expected: &Model{ + KubernetesVersions: types.ListValueMust( + types.ObjectType{AttrTypes: kubernetesVersionType}, + []attr.Value{ + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.31.14"), + "state": types.StringValue("deprecated"), + "expiration_date": types.StringValue(expDeprecated1Str), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.32.10"), + "state": types.StringValue("deprecated"), + "expiration_date": types.StringValue(expDeprecated2Str), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.33.6"), + "state": types.StringValue("deprecated"), + "expiration_date": types.StringValue(expDeprecated2Str), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.34.2"), + "state": types.StringValue("deprecated"), + "expiration_date": types.StringValue(expDeprecated2Str), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.32.11"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.33.7"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.34.3"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + }, + ), + }, + isValid: true, + }, + { + name: "mixed fields with nil feature gates and nil state", + input: &ske.ProviderOptions{ + KubernetesVersions: &[]ske.KubernetesVersion{ + { + Version: skeutils.Ptr("1.32.11"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + FeatureGates: &map[string]string{ + "SomeGate": "foo", + }, + }, + { + Version: nil, + State: nil, + ExpirationDate: nil, + FeatureGates: nil, + }, + }, + }, + model: &Model{}, + expected: &Model{ + KubernetesVersions: types.ListValueMust( + types.ObjectType{AttrTypes: kubernetesVersionType}, + []attr.Value{ + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.32.11"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "SomeGate": types.StringValue("foo"), + }, + ), + }), + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringNull(), + "state": types.StringNull(), + "expiration_date": types.StringNull(), + // nil feature gates => empty map + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + }, + ), + }, + isValid: true, + }, + { + name: "nil kubernetes versions slice", + input: &ske.ProviderOptions{ + KubernetesVersions: nil, + }, + model: &Model{}, + expected: &Model{ + KubernetesVersions: types.ListValueMust( + types.ObjectType{AttrTypes: kubernetesVersionType}, + []attr.Value{}, + ), + }, + isValid: true, + }, + { + name: "empty kubernetes versions slice", + input: &ske.ProviderOptions{ + KubernetesVersions: &[]ske.KubernetesVersion{}, + }, + model: &Model{}, + expected: &Model{ + KubernetesVersions: types.ListValueMust( + types.ObjectType{AttrTypes: kubernetesVersionType}, + []attr.Value{}, + ), + }, + isValid: true, + }, + { + name: "feature gates empty map", + input: &ske.ProviderOptions{ + KubernetesVersions: &[]ske.KubernetesVersion{ + { + Version: skeutils.Ptr("1.33.7"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + FeatureGates: &map[string]string{}, + }, + }, + }, + model: &Model{}, + expected: &Model{ + KubernetesVersions: types.ListValueMust( + types.ObjectType{AttrTypes: kubernetesVersionType}, + []attr.Value{ + types.ObjectValueMust(kubernetesVersionType, map[string]attr.Value{ + "version": types.StringValue("1.33.7"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + // empty map from API => empty map in Terraform + "feature_gates": types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }), + }, + ), + }, + isValid: true, + }, + { + name: "nil model", + input: &ske.ProviderOptions{ + KubernetesVersions: &[]ske.KubernetesVersion{ + { + Version: skeutils.Ptr("1.32.11"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + FeatureGates: &map[string]string{}, + }, + }, + }, + model: nil, + expected: nil, // not used, we expect an error + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, tt.model) + + if tt.isValid && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatal("expected error but got none") + } + + if tt.isValid { + if diff := cmp.Diff(tt.expected, tt.model); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/ske/provideroptions/machineimages/datasource.go b/stackit/internal/services/ske/provideroptions/machineimages/datasource.go new file mode 100644 index 000000000..a5b96c09d --- /dev/null +++ b/stackit/internal/services/ske/provideroptions/machineimages/datasource.go @@ -0,0 +1,264 @@ +package machineimages + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + skeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +// Model types +type Model struct { + Region types.String `tfsdk:"region"` + VersionState types.String `tfsdk:"version_state"` + MachineImages types.List `tfsdk:"machine_images"` +} + +var ( + versionStateOptions = []string{ + "UNSPECIFIED", + "SUPPORTED", + } + + machineImageVersionType = map[string]attr.Type{ + "version": types.StringType, + "state": types.StringType, + "expiration_date": types.StringType, + "cri": types.ListType{ElemType: types.StringType}, + } + + machineImageType = map[string]attr.Type{ + "name": types.StringType, + "versions": types.ListType{ElemType: types.ObjectType{AttrTypes: machineImageVersionType}}, + } +) + +// Ensure implementation satisfies interface +var _ datasource.DataSource = &optionsDataSource{} + +// NewKubernetesMachineImageVersionDataSource creates the data source instance +func NewKubernetesMachineImageVersionDataSource() datasource.DataSource { + return &optionsDataSource{} +} + +type optionsDataSource struct { + client *ske.APIClient + providerData core.ProviderData +} + +// Metadata sets the data source type name. +func (d *optionsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ske_machine_image_versions" +} + +func (d *optionsDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + var ok bool + d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + d.client = skeUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics) + if d.client == nil { + return + } + + tflog.Info(ctx, "SKE machine image versions client configured") +} + +func (d *optionsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + description := "Returns a list of supported Kubernetes machine image versions for the cluster nodes." + + resp.Schema = schema.Schema{ + Description: description, + Attributes: map[string]schema.Attribute{ + "region": schema.StringAttribute{ + Optional: true, + Description: "Region override. If omitted, the provider’s region will be used.", + }, + "version_state": schema.StringAttribute{ + Optional: true, + Description: "Filter returned machine image versions by their state. " + utils.FormatPossibleValues(versionStateOptions...), + Validators: []validator.String{ + stringvalidator.OneOf(versionStateOptions...), + }, + }, + "machine_images": schema.ListNestedAttribute{ + Computed: true, + Description: "Supported machine image types and versions.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Computed: true, + Description: "Name of the OS image (e.g., `ubuntu` or `flatcar`).", + }, + "versions": schema.ListNestedAttribute{ + Computed: true, + Description: "Supported versions of the image.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "version": schema.StringAttribute{ + Computed: true, + Description: "Machine image version string.", + }, + "state": schema.StringAttribute{ + Computed: true, + Description: "State of the image version.", + }, + "expiration_date": schema.StringAttribute{ + Computed: true, + Description: "Expiration date of the version in RFC3339 format.", + }, + "cri": schema.ListAttribute{ + Computed: true, + ElementType: types.StringType, + Description: "Container runtimes supported (e.g., `containerd`).", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *optionsDataSource) 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 + } + + region := d.providerData.GetRegionWithOverride(model.Region) + ctx = core.InitProviderContext(ctx) + ctx = tflog.SetField(ctx, "region", region) + + listProviderOptionsReq := d.client.ListProviderOptions(ctx, region) + + if !utils.IsUndefined(model.VersionState) { + listProviderOptionsReq = listProviderOptionsReq.VersionState(model.VersionState.ValueString()) + } + + optionsResp, err := listProviderOptionsReq.Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading SKE provider options failed", + "Unable to read SKE provider options", + map[int]string{ + http.StatusForbidden: "Forbidden access", + }, + ) + resp.State.RemoveResource(ctx) + return + } + + if err := mapFields(ctx, optionsResp, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading provider options", fmt.Sprintf("Mapping API payload: %v", err)) + return + } + + // Set final state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + tflog.Info(ctx, "Read SKE provider options successfully", map[string]interface{}{ + "region": region, + "versionState": model.VersionState.ValueString(), + }) +} + +func mapFields(ctx context.Context, optionsResp *ske.ProviderOptions, model *Model) error { + if optionsResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + // Machine Images + miList := make([]attr.Value, 0) + if optionsResp.MachineImages != nil { + for _, img := range *optionsResp.MachineImages { + versionsList := make([]attr.Value, 0) + if img.Versions != nil { + for _, ver := range *img.Versions { + // CRI list + criList := make([]types.String, 0) + if ver.Cri != nil { + for _, cri := range *ver.Cri { + if cri.Name != nil { + criList = append(criList, types.StringValue(string(*cri.Name.Ptr()))) + } + } + } + criVal, diags := types.ListValueFrom(ctx, types.StringType, criList) + if diags.HasError() { + return core.DiagsToError(diags) + } + + // Expiration date + expDate := types.StringNull() + if ver.ExpirationDate != nil { + expDate = types.StringValue(ver.ExpirationDate.Format(time.RFC3339)) + } + + versionObj, diags := types.ObjectValue(machineImageVersionType, map[string]attr.Value{ + "version": types.StringPointerValue(ver.Version), + "state": types.StringPointerValue(ver.State), + "expiration_date": expDate, + "cri": criVal, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + versionsList = append(versionsList, versionObj) + } + } + + versions, diags := types.ListValue(types.ObjectType{AttrTypes: machineImageVersionType}, versionsList) + if diags.HasError() { + return core.DiagsToError(diags) + } + + imgObj, diags := types.ObjectValue(machineImageType, map[string]attr.Value{ + "name": types.StringPointerValue(img.Name), + "versions": versions, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + miList = append(miList, imgObj) + } + } + + mis, diags := types.ListValue(types.ObjectType{AttrTypes: machineImageType}, miList) + if diags.HasError() { + return core.DiagsToError(diags) + } + model.MachineImages = mis + + return nil +} diff --git a/stackit/internal/services/ske/provideroptions/machineimages/datasource_test.go b/stackit/internal/services/ske/provideroptions/machineimages/datasource_test.go new file mode 100644 index 000000000..385b57c2b --- /dev/null +++ b/stackit/internal/services/ske/provideroptions/machineimages/datasource_test.go @@ -0,0 +1,358 @@ +package machineimages + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + skeutils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +func TestMapFields(t *testing.T) { + timestamp := time.Date(2026, 1, 14, 8, 0, 0, 0, time.UTC) + expDate := timestamp.Format(time.RFC3339) + + tests := []struct { + name string + input *ske.ProviderOptions + expected *Model + isValid bool + }{ + { + name: "nil input provider options", + input: nil, + expected: &Model{}, + isValid: false, + }, + { + name: "single machine image single version full fields", + input: &ske.ProviderOptions{ + MachineImages: &[]ske.MachineImage{ + { + Name: skeutils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + Version: skeutils.Ptr("4230.2.1"), + State: skeutils.Ptr("supported"), + ExpirationDate: ×tamp, + Cri: &[]ske.CRI{ + { + Name: skeutils.Ptr(ske.CRINAME_CONTAINERD), + }, + }, + }, + }, + }, + }, + }, + expected: &Model{ + MachineImages: types.ListValueMust( + types.ObjectType{AttrTypes: machineImageType}, + []attr.Value{ + types.ObjectValueMust(machineImageType, map[string]attr.Value{ + "name": types.StringValue("flatcar"), + "versions": types.ListValueMust( + types.ObjectType{AttrTypes: machineImageVersionType}, + []attr.Value{ + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("4230.2.1"), + "state": types.StringValue("supported"), + "expiration_date": types.StringValue(expDate), + "cri": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue(string(ske.CRINAME_CONTAINERD)), + }, + ), + }), + }, + ), + }), + }, + ), + }, + isValid: true, + }, + { + name: "single machine image multiple versions mixed fields", + input: &ske.ProviderOptions{ + MachineImages: &[]ske.MachineImage{ + { + Name: skeutils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + Version: skeutils.Ptr("4230.2.1"), + State: skeutils.Ptr("supported"), + ExpirationDate: ×tamp, + Cri: &[]ske.CRI{ + { + Name: skeutils.Ptr(ske.CRINAME_CONTAINERD), + }, + }, + }, + { + // nil version, nil state, no expiration date, no CRI + Version: nil, + State: nil, + ExpirationDate: nil, + Cri: nil, + }, + }, + }, + }, + }, + expected: &Model{ + MachineImages: types.ListValueMust( + types.ObjectType{AttrTypes: machineImageType}, + []attr.Value{ + types.ObjectValueMust(machineImageType, map[string]attr.Value{ + "name": types.StringValue("flatcar"), + "versions": types.ListValueMust( + types.ObjectType{AttrTypes: machineImageVersionType}, + []attr.Value{ + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("4230.2.1"), + "state": types.StringValue("supported"), + "expiration_date": types.StringValue(expDate), + "cri": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue(string(ske.CRINAME_CONTAINERD)), + }, + ), + }), + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringNull(), + "state": types.StringNull(), + "expiration_date": types.StringNull(), + // nil CRI => empty list + "cri": types.ListValueMust( + types.StringType, + []attr.Value{}, + ), + }), + }, + ), + }), + }, + ), + }, + isValid: true, + }, + { + name: "multiple machine images mixed versions", + input: &ske.ProviderOptions{ + MachineImages: &[]ske.MachineImage{ + { + Name: skeutils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + Version: skeutils.Ptr("4230.2.1"), + State: skeutils.Ptr("deprecated"), + ExpirationDate: ×tamp, + Cri: &[]ske.CRI{ + { + Name: skeutils.Ptr(ske.CRINAME_CONTAINERD), + }, + }, + }, + { + Version: skeutils.Ptr("4230.2.3"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, // no expiration + Cri: &[]ske.CRI{ + { + Name: skeutils.Ptr(ske.CRINAME_CONTAINERD), + }, + }, + }, + { + Version: skeutils.Ptr("4459.2.1"), + State: skeutils.Ptr("preview"), + ExpirationDate: nil, + Cri: &[]ske.CRI{ + { + Name: skeutils.Ptr(ske.CRINAME_CONTAINERD), + }, + }, + }, + }, + }, + { + Name: skeutils.Ptr("ubuntu"), + Versions: &[]ske.MachineImageVersion{ + { + Version: skeutils.Ptr("2204.20250728.0"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + // empty CRI slice + Cri: &[]ske.CRI{}, + }, + }, + }, + }, + }, + expected: &Model{ + MachineImages: types.ListValueMust( + types.ObjectType{AttrTypes: machineImageType}, + []attr.Value{ + types.ObjectValueMust(machineImageType, map[string]attr.Value{ + "name": types.StringValue("flatcar"), + "versions": types.ListValueMust( + types.ObjectType{AttrTypes: machineImageVersionType}, + []attr.Value{ + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("4230.2.1"), + "state": types.StringValue("deprecated"), + "expiration_date": types.StringValue(expDate), + "cri": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue(string(ske.CRINAME_CONTAINERD)), + }, + ), + }), + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("4230.2.3"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + "cri": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue(string(ske.CRINAME_CONTAINERD)), + }, + ), + }), + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("4459.2.1"), + "state": types.StringValue("preview"), + "expiration_date": types.StringNull(), + "cri": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue(string(ske.CRINAME_CONTAINERD)), + }, + ), + }), + }, + ), + }), + types.ObjectValueMust(machineImageType, map[string]attr.Value{ + "name": types.StringValue("ubuntu"), + "versions": types.ListValueMust( + types.ObjectType{AttrTypes: machineImageVersionType}, + []attr.Value{ + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("2204.20250728.0"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + // empty CRI slice => empty list + "cri": types.ListValueMust( + types.StringType, + []attr.Value{}, + ), + }), + }, + ), + }), + }, + ), + }, + isValid: true, + }, + { + name: "nil machine images slice", + input: &ske.ProviderOptions{ + MachineImages: nil, + }, + expected: &Model{ + // Expect an empty list, not null + MachineImages: types.ListValueMust( + types.ObjectType{AttrTypes: machineImageType}, + []attr.Value{}, + ), + }, + isValid: true, + }, + { + name: "empty machine images slice", + input: &ske.ProviderOptions{ + MachineImages: &[]ske.MachineImage{}, + }, + expected: &Model{ + MachineImages: types.ListValueMust( + types.ObjectType{AttrTypes: machineImageType}, + []attr.Value{}, + ), + }, + isValid: true, + }, + { + name: "version without cri and without expiration", + input: &ske.ProviderOptions{ + MachineImages: &[]ske.MachineImage{ + { + Name: skeutils.Ptr("ubuntu"), + Versions: &[]ske.MachineImageVersion{ + { + Version: skeutils.Ptr("2204.20250728.0"), + State: skeutils.Ptr("supported"), + ExpirationDate: nil, + Cri: nil, // explicit nil => empty list + }, + }, + }, + }, + }, + expected: &Model{ + MachineImages: types.ListValueMust( + types.ObjectType{AttrTypes: machineImageType}, + []attr.Value{ + types.ObjectValueMust(machineImageType, map[string]attr.Value{ + "name": types.StringValue("ubuntu"), + "versions": types.ListValueMust( + types.ObjectType{AttrTypes: machineImageVersionType}, + []attr.Value{ + types.ObjectValueMust(machineImageVersionType, map[string]attr.Value{ + "version": types.StringValue("2204.20250728.0"), + "state": types.StringValue("supported"), + "expiration_date": types.StringNull(), + "cri": types.ListValueMust( + types.StringType, + []attr.Value{}, + ), + }), + }, + ), + }), + }, + ), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := &Model{} + err := mapFields(context.Background(), tt.input, model) + + if tt.isValid && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid && err == nil { + t.Fatal("expected error but got none") + } + + if tt.isValid { + if diff := cmp.Diff(tt.expected, model); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index b5a2d1796..c862daa98 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -31,6 +31,9 @@ var ( //go:embed testdata/resource-max.tf resourceMax string + + //go:embed testdata/provider-options.tf + dataSourceProviderOptions string ) var skeProviderOptions = NewSkeProviderOptions("flatcar") @@ -91,6 +94,10 @@ var testConfigVarsMax = config.Variables{ "dns_name": config.StringVariable("acc-" + acctest.RandStringFromCharSet(6, acctest.CharSetAlpha) + ".runs.onstackit.cloud"), } +var testConfigDatasource = config.Variables{ + "region": config.StringVariable(testutil.Region), +} + func configVarsMinUpdated() config.Variables { updatedConfig := maps.Clone(testConfigVarsMin) updatedConfig["kubernetes_version_min"] = config.StringVariable(skeProviderOptions.GetUpdateK8sVersion()) @@ -455,6 +462,36 @@ func TestAccSKEMax(t *testing.T) { }) } +func TestAccProviderOption(t *testing.T) { + t.Logf("TestAccProviderOption") + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + ConfigVariables: testConfigDatasource, + Config: testutil.SKEProviderConfig() + "\n" + dataSourceProviderOptions, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_ske_kubernetes_versions.example", "version_state", "SUPPORTED"), + resource.TestCheckResourceAttrSet("data.stackit_ske_kubernetes_versions.example", "kubernetes_versions.0.version"), + resource.TestCheckResourceAttrSet("data.stackit_ske_kubernetes_versions.example", "kubernetes_versions.0.state"), + resource.TestCheckResourceAttr("data.stackit_ske_kubernetes_versions.example", "kubernetes_versions.0.state", "supported"), + + resource.TestCheckResourceAttr("data.stackit_ske_machine_image_versions.example", "version_state", "SUPPORTED"), + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.0.name"), + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.0.versions.0.version"), + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.0.versions.0.state"), + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.0.versions.0.cri.0"), + resource.TestCheckResourceAttr("data.stackit_ske_machine_image_versions.example", "machine_images.0.versions.0.state", "supported"), + + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.1.name"), + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.1.versions.0.version"), + resource.TestCheckResourceAttrSet("data.stackit_ske_machine_image_versions.example", "machine_images.1.versions.0.state"), + ), + }, + }, + }) +} + func testAccCheckSKEDestroy(s *terraform.State) error { ctx := context.Background() var client *ske.APIClient diff --git a/stackit/internal/services/ske/testdata/provider-options.tf b/stackit/internal/services/ske/testdata/provider-options.tf new file mode 100644 index 000000000..610c97bf6 --- /dev/null +++ b/stackit/internal/services/ske/testdata/provider-options.tf @@ -0,0 +1,7 @@ +data "stackit_ske_kubernetes_versions" "example" { + version_state = "SUPPORTED" +} + +data "stackit_ske_machine_image_versions" "example" { + version_state = "SUPPORTED" +} diff --git a/stackit/provider.go b/stackit/provider.go index e5b3e505d..da4e3b893 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -98,6 +98,8 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/snapshots" skeCluster "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/cluster" skeKubeconfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/kubeconfig" + skeKubernetesVersion "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/provideroptions/kubernetesversions" + skeMachineImages "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/provideroptions/machineimages" sqlServerFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/instance" sqlServerFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/user" ) @@ -570,6 +572,8 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource serverUpdateSchedule.NewSchedulesDataSource, serviceAccount.NewServiceAccountDataSource, skeCluster.NewClusterDataSource, + skeKubernetesVersion.NewKubernetesVersionsDataSource, + skeMachineImages.NewKubernetesMachineImageVersionDataSource, resourcepool.NewResourcePoolDataSource, share.NewShareDataSource, exportpolicy.NewExportPolicyDataSource,