diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index ff1021f4..e9cf5df7 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "sort" "strings" "time" @@ -743,6 +744,33 @@ func (r *clusterResource) Create(ctx context.Context, req resource.CreateRequest tflog.Info(ctx, "SKE cluster created") } +func sortK8sVersions(versions []ske.KubernetesVersion) { + sort.Slice(versions, func(i, j int) bool { + v1, v2 := (versions)[i].Version, (versions)[j].Version + if v1 == nil { + return false + } + if v2 == nil { + return true + } + + // we have to make copies of the input strings to add prefixes, + // otherwise we would be changing the passed elements + t1, t2 := *v1, *v2 + + if !strings.HasPrefix(t1, "v") { + t1 = "v" + t1 + } + if !strings.HasPrefix(t2, "v") { + t2 = "v" + t2 + } + return semver.Compare(t1, t2) > 0 + }) +} + +// loadAvailableVersions loads the available k8s and machine versions from the API. +// The k8s versions are sorted descending order, i.e. the latest versions (including previews) +// are listed first func (r *clusterResource) loadAvailableVersions(ctx context.Context) ([]ske.KubernetesVersion, []ske.MachineImage, error) { c := r.skeClient res, err := c.ListProviderOptions(ctx).Execute() @@ -793,7 +821,7 @@ func (r *clusterResource) createOrUpdateCluster(ctx context.Context, diags *diag // cluster vars projectId := model.ProjectId.ValueString() name := model.Name.ValueString() - kubernetes, hasDeprecatedVersion, err := toKubernetesPayload(model, availableKubernetesVersions, currentKubernetesVersion) + kubernetes, hasDeprecatedVersion, err := toKubernetesPayload(model, availableKubernetesVersions, currentKubernetesVersion, diags) if err != nil { core.LogAndAddError(ctx, diags, "Error creating/updating cluster", fmt.Sprintf("Creating cluster config API payload: %v", err)) return @@ -1813,7 +1841,7 @@ func mapExtensions(ctx context.Context, cl *ske.Cluster, m *Model) error { return nil } -func toKubernetesPayload(m *Model, availableVersions []ske.KubernetesVersion, currentKubernetesVersion *string) (kubernetesPayload *ske.Kubernetes, hasDeprecatedVersion bool, err error) { +func toKubernetesPayload(m *Model, availableVersions []ske.KubernetesVersion, currentKubernetesVersion *string, diags *diag.Diagnostics) (kubernetesPayload *ske.Kubernetes, hasDeprecatedVersion bool, err error) { providedVersionMin := m.KubernetesVersionMin.ValueStringPointer() if !m.KubernetesVersion.IsNull() { @@ -1823,7 +1851,7 @@ func toKubernetesPayload(m *Model, availableVersions []ske.KubernetesVersion, cu providedVersionMin = conversion.StringValueToPointer(m.KubernetesVersion) } - versionUsed, hasDeprecatedVersion, err := latestMatchingKubernetesVersion(availableVersions, providedVersionMin, currentKubernetesVersion) + versionUsed, hasDeprecatedVersion, err := latestMatchingKubernetesVersion(availableVersions, providedVersionMin, currentKubernetesVersion, diags) if err != nil { return nil, false, fmt.Errorf("getting latest matching kubernetes version: %w", err) } @@ -1835,9 +1863,7 @@ func toKubernetesPayload(m *Model, availableVersions []ske.KubernetesVersion, cu return k, hasDeprecatedVersion, nil } -func latestMatchingKubernetesVersion(availableVersions []ske.KubernetesVersion, kubernetesVersionMin, currentKubernetesVersion *string) (version *string, deprecated bool, err error) { - deprecated = false - +func latestMatchingKubernetesVersion(availableVersions []ske.KubernetesVersion, kubernetesVersionMin, currentKubernetesVersion *string, diags *diag.Diagnostics) (version *string, deprecated bool, err error) { if availableVersions == nil { return nil, false, fmt.Errorf("nil available kubernetes versions") } @@ -1865,58 +1891,113 @@ func latestMatchingKubernetesVersion(availableVersions []ske.KubernetesVersion, } } - var fullVersion bool - versionExp := validate.FullVersionRegex - versionRegex := regexp.MustCompile(versionExp) - if versionRegex.MatchString(*kubernetesVersionMin) { - fullVersion = true - } + versionRegex := regexp.MustCompile(validate.FullVersionRegex) + fullVersion := versionRegex.MatchString(*kubernetesVersionMin) providedVersionPrefixed := "v" + *kubernetesVersionMin - if !semver.IsValid(providedVersionPrefixed) { return nil, false, fmt.Errorf("provided version is invalid") } - var versionUsed *string - var state *string - var availableVersionsArray []string - // Get the higher available version that matches the major, minor and patch version provided by the user - for _, v := range availableVersions { - if v.State == nil || v.Version == nil { - continue - } - availableVersionsArray = append(availableVersionsArray, *v.Version) - vPreffixed := "v" + *v.Version - - if fullVersion { - // [MAJOR].[MINOR].[PATCH] version provided, match available version - if semver.Compare(vPreffixed, providedVersionPrefixed) == 0 { - versionUsed = v.Version - state = v.State - break - } - } else { - // [MAJOR].[MINOR] version provided, get the latest non-preview patch version - if semver.MajorMinor(vPreffixed) == semver.MajorMinor(providedVersionPrefixed) && - (semver.Compare(vPreffixed, providedVersionPrefixed) == 1 || semver.Compare(vPreffixed, providedVersionPrefixed) == 0) && - (v.State != nil && *v.State != VersionStatePreview) { - versionUsed = v.Version - state = v.State - } - } + var ( + selectedVersion *ske.KubernetesVersion + availableVersionsArray []string + ) + if fullVersion { + availableVersionsArray, selectedVersion = selectFullVersion(availableVersions, providedVersionPrefixed) + } else { + availableVersionsArray, selectedVersion = selectMatchingVersion(availableVersions, providedVersionPrefixed) } - if versionUsed != nil { - deprecated = strings.EqualFold(*state, VersionStateDeprecated) + deprecated = isDeprecated(selectedVersion) + + if isPreview(selectedVersion) { + diags.AddWarning("preview version selected", fmt.Sprintf("only the preview version %q matched the selection criteria", *selectedVersion.Version)) } // Throwing error if we could not match the version with the available versions - if versionUsed == nil { + if selectedVersion == nil { return nil, false, fmt.Errorf("provided version is not one of the available kubernetes versions, available versions are: %s", strings.Join(availableVersionsArray, ",")) } - return versionUsed, deprecated, nil + return selectedVersion.Version, deprecated, nil +} + +func selectFullVersion(availableVersions []ske.KubernetesVersion, kubernetesVersionMin string) (availableVersionsArray []string, selectedVersion *ske.KubernetesVersion) { + for _, versionCandidate := range availableVersions { + if versionCandidate.State == nil || versionCandidate.Version == nil { + continue + } + availableVersionsArray = append(availableVersionsArray, *versionCandidate.Version) + vPrefixed := "v" + *versionCandidate.Version + + // [MAJOR].[MINOR].[PATCH] version provided, match available version + if semver.Compare(vPrefixed, kubernetesVersionMin) == 0 { + selectedVersion = &versionCandidate + break + } + } + return availableVersionsArray, selectedVersion +} + +func selectMatchingVersion(availableVersions []ske.KubernetesVersion, kubernetesVersionMin string) (availableVersionsArray []string, selectedVersion *ske.KubernetesVersion) { + sortK8sVersions(availableVersions) + for _, candidateVersion := range availableVersions { + if candidateVersion.State == nil || candidateVersion.Version == nil { + continue + } + availableVersionsArray = append(availableVersionsArray, *candidateVersion.Version) + vPreffixed := "v" + *candidateVersion.Version + + // [MAJOR].[MINOR] version provided, get the latest non-preview patch version + if semver.MajorMinor(vPreffixed) == semver.MajorMinor(kubernetesVersionMin) && + (semver.Compare(vPreffixed, kubernetesVersionMin) >= 0) && + (candidateVersion.State != nil) { + // take the current version as a candidate, if we have no other version inspected before + // OR the previously found version was a preview version + if selectedVersion == nil || (isSupported(&candidateVersion) && isPreview(selectedVersion)) { + selectedVersion = &candidateVersion + } + // all other cases are ignored + } + } + return availableVersionsArray, selectedVersion +} + +func isDeprecated(v *ske.KubernetesVersion) bool { + if v == nil { + return false + } + + if v.State == nil { + return false + } + + return *v.State == VersionStateDeprecated +} + +func isPreview(v *ske.KubernetesVersion) bool { + if v == nil { + return false + } + + if v.State == nil { + return false + } + + return *v.State == VersionStatePreview +} + +func isSupported(v *ske.KubernetesVersion) bool { + if v == nil { + return false + } + + if v.State == nil { + return false + } + + return *v.State == VersionStateSupported } func getLatestSupportedKubernetesVersion(versions []ske.KubernetesVersion) (*string, error) { diff --git a/stackit/internal/services/ske/cluster/resource_test.go b/stackit/internal/services/ske/cluster/resource_test.go index d1f260e2..4f5e4f5c 100644 --- a/stackit/internal/services/ske/cluster/resource_test.go +++ b/stackit/internal/services/ske/cluster/resource_test.go @@ -3,11 +3,13 @@ package ske import ( "context" "fmt" + "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" @@ -709,6 +711,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { currentKubernetesVersion *string expectedVersionUsed *string expectedHasDeprecatedVersion bool + expectedWarning bool isValid bool }{ { @@ -735,6 +738,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.20.1"), false, + false, true, }, { @@ -761,6 +765,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.20.0"), false, + false, true, }, { @@ -787,6 +792,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.20.2"), false, + false, true, }, { @@ -813,6 +819,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.20.1"), false, + false, true, }, { @@ -831,6 +838,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.20.0"), false, + false, true, }, { @@ -849,6 +857,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.19.0"), true, + false, true, }, { @@ -868,6 +877,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { utils.Ptr("1.20.0"), false, true, + true, }, { "nil_provided_version_get_latest", @@ -885,6 +895,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, utils.Ptr("1.20.0"), false, + false, true, }, { @@ -903,6 +914,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { utils.Ptr("1.19.0"), utils.Ptr("1.19.0"), false, + false, true, }, { @@ -921,6 +933,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { utils.Ptr("1.20.0"), utils.Ptr("1.20.0"), false, + false, true, }, { @@ -943,6 +956,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { utils.Ptr("1.20.0"), utils.Ptr("1.20.0"), true, + false, true, }, { @@ -961,6 +975,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { utils.Ptr("1.20.0"), utils.Ptr("1.20.0"), false, + false, true, }, { @@ -979,6 +994,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { utils.Ptr("1.19.0"), utils.Ptr("1.20.0"), false, + false, true, }, { @@ -998,6 +1014,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "no_matching_available_versions_patch", @@ -1020,6 +1037,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "no_matching_available_versions_patch_2", @@ -1042,6 +1060,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "no_matching_available_versions_patch_current", @@ -1064,6 +1083,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "no_matching_available_versions_patch_2_current", @@ -1086,6 +1106,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "no_available_version", @@ -1095,6 +1116,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "nil_available_version", @@ -1104,6 +1126,7 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, }, { "empty_provided_version", @@ -1122,11 +1145,45 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { nil, false, false, + false, + }, + { + description: "minimum_version_without_patch_version_results_in_latest_supported_version,even_if_preview_is_available", + availableVersions: []ske.KubernetesVersion{ + {Version: utils.Ptr("1.20.0"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.20.1"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.20.2"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.20.3"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.20.4"), State: utils.Ptr(VersionStatePreview)}, + }, + kubernetesVersionMin: utils.Ptr("1.20"), + currentKubernetesVersion: nil, + expectedVersionUsed: utils.Ptr("1.20.3"), + expectedHasDeprecatedVersion: false, + expectedWarning: false, + isValid: true, + }, + { + description: "use_preview_when_no_supported_release_is_available", + availableVersions: []ske.KubernetesVersion{ + {Version: utils.Ptr("1.19.5"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.19.6"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.19.7"), State: utils.Ptr(VersionStateSupported)}, + {Version: utils.Ptr("1.20.0"), State: utils.Ptr(VersionStateDeprecated)}, + {Version: utils.Ptr("1.20.1"), State: utils.Ptr(VersionStatePreview)}, + }, + kubernetesVersionMin: utils.Ptr("1.20"), + currentKubernetesVersion: nil, + expectedVersionUsed: utils.Ptr("1.20.1"), + expectedHasDeprecatedVersion: false, + expectedWarning: true, + isValid: true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - versionUsed, hasDeprecatedVersion, err := latestMatchingKubernetesVersion(tt.availableVersions, tt.kubernetesVersionMin, tt.currentKubernetesVersion) + var diags diag.Diagnostics + versionUsed, hasDeprecatedVersion, err := latestMatchingKubernetesVersion(tt.availableVersions, tt.kubernetesVersionMin, tt.currentKubernetesVersion, &diags) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -1141,6 +1198,9 @@ func TestLatestMatchingKubernetesVersion(t *testing.T) { t.Fatalf("hasDeprecatedVersion flag is wrong: expecting %t, got %t", tt.expectedHasDeprecatedVersion, hasDeprecatedVersion) } } + if hasWarnings := len(diags.Warnings()) > 0; tt.expectedWarning != hasWarnings { + t.Fatalf("Emitted warnings do not match. Expected %t but got %t", tt.expectedWarning, hasWarnings) + } }) } } @@ -2368,9 +2428,9 @@ func TestMaintenanceWindow(t *testing.T) { "enable_machine_image_version_updates": basetypes.NewBoolValue(false), } - val, diag := basetypes.NewObjectValue(attributeTypes, attributeValues) - if diag.HasError() { - t.Fatalf("cannot create object value: %v", diag) + val, diags := basetypes.NewObjectValue(attributeTypes, attributeValues) + if diags.HasError() { + t.Fatalf("cannot create object value: %v", diags) } model := Model{ Maintenance: val, @@ -2400,3 +2460,95 @@ func TestMaintenanceWindow(t *testing.T) { }) } } + +func TestSortK8sVersion(t *testing.T) { + testcases := []struct { + description string + versions []ske.KubernetesVersion + wantSorted []ske.KubernetesVersion + }{ + { + description: "slice with well formed elements", + versions: []ske.KubernetesVersion{ + {Version: utils.Ptr("v1.2.3")}, + {Version: utils.Ptr("v1.1.10")}, + {Version: utils.Ptr("v1.2.1")}, + {Version: utils.Ptr("v1.2.0")}, + {Version: utils.Ptr("v1.1")}, + {Version: utils.Ptr("v1.2.2")}, + }, + wantSorted: []ske.KubernetesVersion{ + {Version: utils.Ptr("v1.2.3")}, + {Version: utils.Ptr("v1.2.2")}, + {Version: utils.Ptr("v1.2.1")}, + {Version: utils.Ptr("v1.2.0")}, + {Version: utils.Ptr("v1.1.10")}, + {Version: utils.Ptr("v1.1")}, + }, + }, + { + description: "slice with undefined elements", + versions: []ske.KubernetesVersion{ + {Version: utils.Ptr("v1.2.3")}, + {Version: utils.Ptr("v1.1.10")}, + {}, + {Version: utils.Ptr("v1.2.0")}, + {Version: utils.Ptr("v1.1")}, + {Version: utils.Ptr("v1.2.2")}, + }, + wantSorted: []ske.KubernetesVersion{ + {Version: utils.Ptr("v1.2.3")}, + {Version: utils.Ptr("v1.2.2")}, + {Version: utils.Ptr("v1.2.0")}, + {Version: utils.Ptr("v1.1.10")}, + {Version: utils.Ptr("v1.1")}, + {Version: nil}, + }, + }, + { + description: "slice without prefix and minor version change", + versions: []ske.KubernetesVersion{ + {Version: utils.Ptr("1.20.0")}, + {Version: utils.Ptr("1.19.0")}, + {Version: utils.Ptr("1.20.1")}, + {Version: utils.Ptr("1.20.2")}, + }, + wantSorted: []ske.KubernetesVersion{ + {Version: utils.Ptr("1.20.2")}, + {Version: utils.Ptr("1.20.1")}, + {Version: utils.Ptr("1.20.0")}, + {Version: utils.Ptr("1.19.0")}, + }, + }, + { + description: "empty slice", + }, + } + for _, tc := range testcases { + t.Run(tc.description, func(t *testing.T) { + sortK8sVersions(tc.versions) + + joinK8sVersions := func(in []ske.KubernetesVersion, sep string) string { + var builder strings.Builder + for i, l := 0, len(in); i < l; i++ { + if i > 0 { + builder.WriteString(sep) + } + if v := in[i].Version; v != nil { + builder.WriteString(*v) + } else { + builder.WriteString("undef") + } + } + return builder.String() + } + + expected := joinK8sVersions(tc.wantSorted, ", ") + actual := joinK8sVersions(tc.versions, ", ") + + if expected != actual { + t.Errorf("wrong sort order. wanted %s but got %s", expected, actual) + } + }) + } +}