Skip to content

✨ envtest: search the assets index for latest of a release series #3280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion examples/scratch-env/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions examples/scratch-env/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 56 additions & 15 deletions pkg/envtest/binaries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add an else branch here that returns to cover error cases?


binaryAssetsVersion, err = latestStableVersionFromIndex(binaryAssetsIndex)
binaryAssetsVersion, err = latestStableVersionFromIndex(binaryAssetsIndex, major, minor)
if err != nil {
return "", "", "", err
}
Expand Down Expand Up @@ -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) {
Expand Down
129 changes: 127 additions & 2 deletions pkg/envtest/binaries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,97 @@ import (
"os"
"path"
"runtime"
"strings"
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/ghttp"
"sigs.k8s.io/yaml"
)

func TestInterpretKubernetesVersion(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
inputs []string

expectExact bool
expectSeriesMajor uint
expectSeriesMinor uint
}{
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a test case for the empty string

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
Expand Down Expand Up @@ -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")))
Expand All @@ -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 (
Expand All @@ -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": {},
Expand Down