feat: include preview versions as fallback (#674)

* feat: include preview versions as fallback

* feat: cleanup code

* fix: review findings

* fix: review findings

* fix: linter warning

* fix: applied review comments
This commit is contained in:
Rüdiger Schmitz 2025-02-17 16:55:08 +01:00 committed by GitHub
parent f0168cfed9
commit bb472001de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 281 additions and 48 deletions

View file

@ -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) {

View file

@ -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)
}
})
}
}