From 95c76a791d8cf7a6e7581f8df6ca9009f037f371 Mon Sep 17 00:00:00 2001 From: Chris Bandy Date: Wed, 13 Aug 2025 17:26:31 -0500 Subject: [PATCH 1/2] envtest: replace github.com/blang/semver with k8s.io/apimachinery The former is no longer maintained and the latter is designed to understand Kubernetes versions. This eliminates one direct dependency introduced in b04d5fd227d68024f5e0db9f40cbd9e3fb4045da. Signed-off-by: Chris Bandy --- examples/scratch-env/go.mod | 1 - examples/scratch-env/go.sum | 2 -- go.mod | 2 +- pkg/envtest/binaries.go | 10 +++++----- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/examples/scratch-env/go.mod b/examples/scratch-env/go.mod index a483b39324..a4c13fedfd 100644 --- a/examples/scratch-env/go.mod +++ b/examples/scratch-env/go.mod @@ -10,7 +10,6 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect diff --git a/examples/scratch-env/go.sum b/examples/scratch-env/go.sum index 417738e821..d0b6bd93f4 100644 --- a/examples/scratch-env/go.sum +++ b/examples/scratch-env/go.sum @@ -1,7 +1,5 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/go.mod b/go.mod index 2bafd0f92c..eac1e1b45e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module sigs.k8s.io/controller-runtime go 1.24.0 require ( - github.com/blang/semver/v4 v4.0.0 github.com/evanphx/json-patch/v5 v5.9.11 github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.2 @@ -37,6 +36,7 @@ require ( cel.dev/expr v0.24.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/pkg/envtest/binaries.go b/pkg/envtest/binaries.go index 4c9b1dae38..9129cc2b35 100644 --- a/pkg/envtest/binaries.go +++ b/pkg/envtest/binaries.go @@ -35,7 +35,7 @@ import ( "sort" "strings" - "github.com/blang/semver/v4" + "k8s.io/apimachinery/pkg/util/version" "sigs.k8s.io/yaml" ) @@ -257,15 +257,15 @@ func latestStableVersionFromIndex(index *index) (string, error) { return "", fmt.Errorf("failed to find latest stable version from index: index is empty") } - parsedVersions := []semver.Version{} + parsedVersions := []*version.Version{} for releaseVersion := range index.Releases { - v, err := semver.ParseTolerant(releaseVersion) + v, err := version.ParseSemantic(releaseVersion) if err != nil { return "", fmt.Errorf("failed to parse version %q: %w", releaseVersion, err) } // Filter out pre-releases. - if len(v.Pre) > 0 { + if len(v.PreRelease()) > 0 { continue } @@ -277,7 +277,7 @@ func latestStableVersionFromIndex(index *index) (string, error) { } sort.Slice(parsedVersions, func(i, j int) bool { - return parsedVersions[i].GT(parsedVersions[j]) + return parsedVersions[i].GreaterThan(parsedVersions[j]) }) return "v" + parsedVersions[0].String(), nil } From 9b9b2d302771216c10a033de6146516cb1f47d1c Mon Sep 17 00:00:00 2001 From: Chris Bandy Date: Wed, 13 Aug 2025 23:52:52 -0500 Subject: [PATCH 2/2] envtest: search the assets index for latest of a release series Signed-off-by: Chris Bandy --- pkg/envtest/binaries.go | 69 +++++++++++++--- pkg/envtest/binaries_test.go | 149 ++++++++++++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 15 deletions(-) diff --git a/pkg/envtest/binaries.go b/pkg/envtest/binaries.go index 9129cc2b35..5110d32658 100644 --- a/pkg/envtest/binaries.go +++ b/pkg/envtest/binaries.go @@ -32,7 +32,6 @@ import ( "path" "path/filepath" "runtime" - "sort" "strings" "k8s.io/apimachinery/pkg/util/version" @@ -111,6 +110,25 @@ type archive struct { SelfLink string `json:"selfLink"` } +// parseKubernetesVersion returns: +// 1. the SemVer form of s when it refers to a specific Kubernetes release, or +// 2. the major and minor portions of s when it refers to a release series, or +// 3. an error +func parseKubernetesVersion(s string) (exact string, major, minor uint, err error) { + if v, err := version.ParseSemantic(s); err == nil { + return v.String(), 0, 0, nil + } + + // See two parseable components and nothing else. + if v, err := version.ParseGeneric(s); err == nil && len(v.Components()) == 2 { + if v.String() == strings.TrimPrefix(s, "v") { + return "", v.Major(), v.Minor(), nil + } + } + + return "", 0, 0, fmt.Errorf("could not parse %q as version", s) +} + func downloadBinaryAssets(ctx context.Context, binaryAssetsDirectory, binaryAssetsVersion, binaryAssetsIndexURL string) (string, string, string, error) { if binaryAssetsIndexURL == "" { binaryAssetsIndexURL = DefaultBinaryAssetsIndexURL @@ -125,14 +143,23 @@ func downloadBinaryAssets(ctx context.Context, binaryAssetsDirectory, binaryAsse } var binaryAssetsIndex *index - if binaryAssetsVersion == "" { - var err error + switch exact, major, minor, err := parseKubernetesVersion(binaryAssetsVersion); { + case binaryAssetsVersion != "" && err != nil: + return "", "", "", err + + case binaryAssetsVersion != "" && exact != "": + // Look for these specific binaries locally before downloading them from the release index. + // Use the canonical form of the version from here on. + binaryAssetsVersion = "v" + exact + + case binaryAssetsVersion == "" || major != 0 || minor != 0: + // Select a stable version from the release index before continuing. binaryAssetsIndex, err = getIndex(ctx, binaryAssetsIndexURL) if err != nil { return "", "", "", err } - binaryAssetsVersion, err = latestStableVersionFromIndex(binaryAssetsIndex) + binaryAssetsVersion, err = latestStableVersionFromIndex(binaryAssetsIndex, major, minor) if err != nil { return "", "", "", err } @@ -252,12 +279,19 @@ func downloadBinaryAssetsArchive(ctx context.Context, index *index, version stri return readBody(resp, out, archiveName, archive.Hash) } -func latestStableVersionFromIndex(index *index) (string, error) { +// latestStableVersionFromIndex returns the version with highest [precedence] in index that is not a prerelease. +// When either major or minor are not zero, the returned version will have those major and minor versions. +// Note that the version cannot be limited to 0.0.x this way. +// +// It is an error when there is no appropriate version in index. +// +// [precedence]: https://semver.org/spec/v2.0.0.html#spec-item-11 +func latestStableVersionFromIndex(index *index, major, minor uint) (string, error) { if len(index.Releases) == 0 { return "", fmt.Errorf("failed to find latest stable version from index: index is empty") } - parsedVersions := []*version.Version{} + var found *version.Version for releaseVersion := range index.Releases { v, err := version.ParseSemantic(releaseVersion) if err != nil { @@ -269,17 +303,26 @@ func latestStableVersionFromIndex(index *index) (string, error) { continue } - parsedVersions = append(parsedVersions, v) + // Filter on release series, if any. + if (major != 0 || minor != 0) && (v.Major() != major || v.Minor() != minor) { + continue + } + + if found == nil || v.GreaterThan(found) { + found = v + } } - if len(parsedVersions) == 0 { - return "", fmt.Errorf("failed to find latest stable version from index: index does not have stable versions") + if found == nil { + search := "any" + if major != 0 || minor != 0 { + search = fmt.Sprint(major, ".", minor) + } + + return "", fmt.Errorf("failed to find latest stable version from index: index does not have %s stable versions", search) } - sort.Slice(parsedVersions, func(i, j int) bool { - return parsedVersions[i].GreaterThan(parsedVersions[j]) - }) - return "v" + parsedVersions[0].String(), nil + return "v" + found.String(), nil } func getIndex(ctx context.Context, indexURL string) (*index, error) { diff --git a/pkg/envtest/binaries_test.go b/pkg/envtest/binaries_test.go index e5865cbc70..aa83963381 100644 --- a/pkg/envtest/binaries_test.go +++ b/pkg/envtest/binaries_test.go @@ -28,6 +28,8 @@ import ( "os" "path" "runtime" + "strings" + "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,6 +37,108 @@ import ( "sigs.k8s.io/yaml" ) +func TestParseKubernetesVersion(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + inputs []string + + expectError string + expectExact bool + expectSeriesMajor uint + expectSeriesMinor uint + }{ + { + name: `SemVer and "v" prefix are exact`, + inputs: []string{ + "1.2.3", "v1.2.3", "v1.30.2", "v1.31.0-beta.0", "v1.33.0-alpha.2", + }, + expectExact: true, + }, + { + name: "empty string is not a version", + inputs: []string{""}, + expectError: "could not parse", + }, + { + name: "leading zeroes are not a version", + inputs: []string{ + "01.2.0", "00001.2.3", "1.2.03", "v01.02.0003", + }, + expectError: "could not parse", + }, + { + name: "weird stuff is not a version", + inputs: []string{ + "asdf", "version", "vegeta4", "the.1", "2ne1", "=7.8.9", "10.x", "*", + "0.0001", "1.00002", "v1.2anything", "1.2.x", "1.2.z", "1.2.*", + }, + expectError: "could not parse", + }, + { + name: "one number is not a version", + inputs: []string{ + "1", "v1", "v001", "1.", "v1.", "1.x", + }, + expectError: "could not parse", + }, + { + name: "two numbers are a release series", + inputs: []string{"0.1", "v0.1"}, + + expectSeriesMajor: 0, + expectSeriesMinor: 1, + }, + { + name: "two numbers are a release series", + inputs: []string{"1.2", "v1.2"}, + + expectSeriesMajor: 1, + expectSeriesMinor: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, input := range tc.inputs { + exact, major, minor, err := parseKubernetesVersion(input) + + if tc.expectError != "" && err == nil { + t.Errorf("expected error %q, got none", tc.expectError) + } + if tc.expectError != "" && !strings.Contains(err.Error(), tc.expectError) { + t.Errorf("expected error %q, got %q", tc.expectError, err) + } + if tc.expectError == "" && err != nil { + t.Errorf("expected no error, got %q", err) + continue + } + + if tc.expectExact { + if expected := strings.TrimPrefix(input, "v"); exact != expected { + t.Errorf("expected canonical %q for %q, got %q", expected, input, exact) + } + if major != 0 || minor != 0 { + t.Errorf("expected no release series for %q, got (%v, %v)", input, major, minor) + } + continue + } + + if major != tc.expectSeriesMajor { + t.Errorf("expected major %v for %q, got %v", tc.expectSeriesMajor, input, major) + } + if minor != tc.expectSeriesMinor { + t.Errorf("expected minor %v for %q, got %v", tc.expectSeriesMinor, input, minor) + } + if exact != "" { + t.Errorf("expected no canonical version for %q, got %q", input, exact) + } + } + }) + } +} + var _ = Describe("Test download binaries", func() { var downloadDirectory string var server *ghttp.Server @@ -68,11 +172,11 @@ var _ = Describe("Test download binaries", func() { Expect(actualFiles).To(ConsistOf("some-file")) }) - It("should download v1.32.0 binaries", func(ctx SpecContext) { + It("should download binaries of an exact version", func(ctx SpecContext) { apiServerPath, etcdPath, kubectlPath, err := downloadBinaryAssets(ctx, downloadDirectory, "v1.31.0", fmt.Sprintf("http://%s/%s", server.Addr(), "envtest-releases.yaml")) Expect(err).ToNot(HaveOccurred()) - // Verify latest stable version (v1.32.0) was downloaded + // Verify exact version (v1.31.0) was downloaded versionDownloadDirectory := path.Join(downloadDirectory, fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)) Expect(apiServerPath).To(Equal(path.Join(versionDownloadDirectory, "kube-apiserver"))) Expect(etcdPath).To(Equal(path.Join(versionDownloadDirectory, "etcd"))) @@ -86,6 +190,38 @@ var _ = Describe("Test download binaries", func() { } Expect(actualFiles).To(ConsistOf("some-file")) }) + + It("should download binaries of latest stable version of a release series", func(ctx SpecContext) { + apiServerPath, etcdPath, kubectlPath, err := downloadBinaryAssets(ctx, downloadDirectory, "1.31", fmt.Sprintf("http://%s/%s", server.Addr(), "envtest-releases.yaml")) + Expect(err).ToNot(HaveOccurred()) + + // Verify stable version (v1.31.4) was downloaded + versionDownloadDirectory := path.Join(downloadDirectory, fmt.Sprintf("1.31.4-%s-%s", runtime.GOOS, runtime.GOARCH)) + Expect(apiServerPath).To(Equal(path.Join(versionDownloadDirectory, "kube-apiserver"))) + Expect(etcdPath).To(Equal(path.Join(versionDownloadDirectory, "etcd"))) + Expect(kubectlPath).To(Equal(path.Join(versionDownloadDirectory, "kubectl"))) + + dirEntries, err := os.ReadDir(versionDownloadDirectory) + Expect(err).ToNot(HaveOccurred()) + var actualFiles []string + for _, e := range dirEntries { + actualFiles = append(actualFiles, e.Name()) + } + Expect(actualFiles).To(ConsistOf("some-file")) + }) + + It("should error when the asset version is not a version", func(ctx SpecContext) { + _, _, _, err := downloadBinaryAssets(ctx, downloadDirectory, "wonky", fmt.Sprintf("http://%s/%s", server.Addr(), "envtest-releases.yaml")) + Expect(err).To(MatchError(`could not parse "wonky" as version`)) + }) + + It("should error when the asset version is not in the index", func(ctx SpecContext) { + _, _, _, err := downloadBinaryAssets(ctx, downloadDirectory, "v1.5.0", fmt.Sprintf("http://%s/%s", server.Addr(), "envtest-releases.yaml")) + Expect(err).To(MatchError("failed to find envtest binaries for version v1.5.0")) + + _, _, _, err = downloadBinaryAssets(ctx, downloadDirectory, "v1.5", fmt.Sprintf("http://%s/%s", server.Addr(), "envtest-releases.yaml")) + Expect(err).To(MatchError("failed to find latest stable version from index: index does not have 1.5 stable versions")) + }) }) var ( @@ -100,6 +236,15 @@ var ( "envtest-v1.32.0-linux-s390x.tar.gz": {}, "envtest-v1.32.0-windows-amd64.tar.gz": {}, }, + "v1.31.4": map[string]archive{ + "envtest-v1.31.4-darwin-amd64.tar.gz": {}, + "envtest-v1.31.4-darwin-arm64.tar.gz": {}, + "envtest-v1.31.4-linux-amd64.tar.gz": {}, + "envtest-v1.31.4-linux-arm64.tar.gz": {}, + "envtest-v1.31.4-linux-ppc64le.tar.gz": {}, + "envtest-v1.31.4-linux-s390x.tar.gz": {}, + "envtest-v1.31.4-windows-amd64.tar.gz": {}, + }, "v1.31.0": map[string]archive{ "envtest-v1.31.0-darwin-amd64.tar.gz": {}, "envtest-v1.31.0-darwin-arm64.tar.gz": {},