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..eda093460c 100644 --- a/pkg/envtest/binaries.go +++ b/pkg/envtest/binaries.go @@ -32,10 +32,9 @@ import ( "path" "path/filepath" "runtime" - "sort" "strings" - "github.com/blang/semver/v4" + "k8s.io/apimachinery/pkg/util/version" "sigs.k8s.io/yaml" ) @@ -111,6 +110,25 @@ type archive struct { SelfLink string `json:"selfLink"` } +// interpretKubernetesVersion 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. zero values +func interpretKubernetesVersion(s string) (exact string, major, minor uint) { + if v, err := version.ParseSemantic(s); err == nil { + return v.String(), 0, 0 + } + + // 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() + } + } + + return "", 0, 0 +} + func downloadBinaryAssets(ctx context.Context, binaryAssetsDirectory, binaryAssetsVersion, binaryAssetsIndexURL string) (string, string, string, error) { if binaryAssetsIndexURL == "" { binaryAssetsIndexURL = DefaultBinaryAssetsIndexURL @@ -124,15 +142,22 @@ func downloadBinaryAssets(ctx context.Context, binaryAssetsDirectory, binaryAsse } } + exact, major, minor := interpretKubernetesVersion(binaryAssetsVersion) + var binaryAssetsIndex *index - if binaryAssetsVersion == "" { + if 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 + } else if binaryAssetsVersion == "" || major != 0 || minor != 0 { + // Select a stable version from the release index before continuing. var err error 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,34 +277,50 @@ 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 := []semver.Version{} + var found *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 + } + + // Filter on release series, if any. + if (major != 0 || minor != 0) && (v.Major() != major || v.Minor() != minor) { continue } - parsedVersions = append(parsedVersions, v) + 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].GT(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..8b9b5241f1 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,88 @@ import ( "sigs.k8s.io/yaml" ) +func TestInterpretKubernetesVersion(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + inputs []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: "leading zeroes are not a version", + inputs: []string{ + "01.2.0", "00001.2.3", "1.2.03", "v01.02.0003", + }, + }, + { + 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.*", + }, + }, + { + name: "one number is not a version", + inputs: []string{ + "1", "v1", "v001", "1.", "v1.", "1.x", + }, + }, + { + 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 := interpretKubernetesVersion(input) + + 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 +152,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 +170,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("failed to find envtest binaries for version wonky")) + }) + + 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 +216,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": {},