Skip to content

Commit b04d5fd

Browse files
authored
✨ envtest: add option to download binaries, bump envtest to v1.32.0 (#3135)
* envtest: add option to download binaries, bump envtest to v1.32.0 Signed-off-by: Stefan Büringer [email protected] * Fix review findings --------- Signed-off-by: Stefan Büringer [email protected]
1 parent 9d8d219 commit b04d5fd

File tree

8 files changed

+596
-5
lines changed

8 files changed

+596
-5
lines changed

examples/scratch-env/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010

1111
require (
1212
github.com/beorn7/perks v1.0.1 // indirect
13+
github.com/blang/semver/v4 v4.0.0 // indirect
1314
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1415
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
1516
github.com/emicklei/go-restful/v3 v3.11.0 // indirect

examples/scratch-env/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
22
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3+
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
4+
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
35
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
46
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
57
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module sigs.k8s.io/controller-runtime
33
go 1.23.0
44

55
require (
6+
github.com/blang/semver/v4 v4.0.0
67
github.com/evanphx/json-patch/v5 v5.9.11
78
github.com/fsnotify/fsnotify v1.7.0
89
github.com/go-logr/logr v1.4.2
@@ -35,7 +36,6 @@ require (
3536
cel.dev/expr v0.19.1 // indirect
3637
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
3738
github.com/beorn7/perks v1.0.1 // indirect
38-
github.com/blang/semver/v4 v4.0.0 // indirect
3939
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
4040
github.com/cespare/xxhash/v2 v2.3.0 // indirect
4141
github.com/davecgh/go-spew v1.1.1 // indirect

hack/check-everything.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export GOTOOLCHAIN="go$(make --silent go-version)"
3030
${hack_dir}/verify.sh
3131

3232
# Envtest.
33-
ENVTEST_K8S_VERSION=${ENVTEST_K8S_VERSION:-"1.28.0"}
33+
ENVTEST_K8S_VERSION=${ENVTEST_K8S_VERSION:-"1.32.0"}
3434

3535
header_text "installing envtest tools@${ENVTEST_K8S_VERSION} with setup-envtest if necessary"
3636
tmp_bin=/tmp/cr-tests-bin

pkg/client/apiutil/restmapper_test.go

+5
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ func setupEnvtest(t *testing.T, disableAggregatedDiscovery bool) *rest.Config {
7777
CRDDirectoryPaths: []string{"testdata"},
7878
}
7979
if disableAggregatedDiscovery {
80+
testEnv.DownloadBinaryAssets = true
81+
testEnv.DownloadBinaryAssetsVersion = "v1.28.0"
82+
binaryAssetsDirectory, err := envtest.SetupEnvtestDefaultBinaryAssetsDirectory()
83+
g.Expect(err).ToNot(gmg.HaveOccurred())
84+
testEnv.BinaryAssetsDirectory = binaryAssetsDirectory
8085
testEnv.ControlPlane.GetAPIServer().Configure().Append("feature-gates", "AggregatedDiscoveryEndpoint=false")
8186
}
8287

pkg/envtest/binaries.go

+344
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package envtest
18+
19+
import (
20+
"archive/tar"
21+
"bytes"
22+
"compress/gzip"
23+
"context"
24+
"crypto/sha512"
25+
"encoding/hex"
26+
"errors"
27+
"fmt"
28+
"io"
29+
"net/http"
30+
"net/url"
31+
"os"
32+
"path"
33+
"path/filepath"
34+
"runtime"
35+
"sort"
36+
"strings"
37+
38+
"github.com/blang/semver/v4"
39+
"sigs.k8s.io/yaml"
40+
)
41+
42+
// DefaultBinaryAssetsIndexURL is the default index used in HTTPClient.
43+
var DefaultBinaryAssetsIndexURL = "https://raw.githubusercontent.com/kubernetes-sigs/controller-tools/HEAD/envtest-releases.yaml"
44+
45+
// SetupEnvtestDefaultBinaryAssetsDirectory returns the default location that setup-envtest uses to store envtest binaries.
46+
// Setting BinaryAssetsDirectory to this directory allows sharing envtest binaries with setup-envtest.
47+
//
48+
// The directory is dependent on operating system:
49+
//
50+
// - Windows: %LocalAppData%\kubebuilder-envtest
51+
// - OSX: ~/Library/Application Support/io.kubebuilder.envtest
52+
// - Others: ${XDG_DATA_HOME:-~/.local/share}/kubebuilder-envtest
53+
//
54+
// Otherwise, it errors out. Note that these paths must not be relied upon
55+
// manually.
56+
func SetupEnvtestDefaultBinaryAssetsDirectory() (string, error) {
57+
var baseDir string
58+
59+
// find the base data directory
60+
switch runtime.GOOS {
61+
case "windows":
62+
baseDir = os.Getenv("LocalAppData")
63+
if baseDir == "" {
64+
return "", errors.New("%LocalAppData% is not defined")
65+
}
66+
case "darwin":
67+
homeDir := os.Getenv("HOME")
68+
if homeDir == "" {
69+
return "", errors.New("$HOME is not defined")
70+
}
71+
baseDir = filepath.Join(homeDir, "Library/Application Support")
72+
default:
73+
baseDir = os.Getenv("XDG_DATA_HOME")
74+
if baseDir == "" {
75+
homeDir := os.Getenv("HOME")
76+
if homeDir == "" {
77+
return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined")
78+
}
79+
baseDir = filepath.Join(homeDir, ".local/share")
80+
}
81+
}
82+
83+
// append our program-specific dir to it (OSX has a slightly different
84+
// convention so try to follow that).
85+
switch runtime.GOOS {
86+
case "darwin", "ios":
87+
return filepath.Join(baseDir, "io.kubebuilder.envtest", "k8s"), nil
88+
default:
89+
return filepath.Join(baseDir, "kubebuilder-envtest", "k8s"), nil
90+
}
91+
}
92+
93+
// index represents an index of envtest binary archives. Example:
94+
//
95+
// releases:
96+
// v1.28.0:
97+
// envtest-v1.28.0-darwin-amd64.tar.gz:
98+
// hash: <sha512-hash>
99+
// selfLink: <url-to-archive-with-envtest-binaries>
100+
type index struct {
101+
// Releases maps Kubernetes versions to Releases (envtest archives).
102+
Releases map[string]release `json:"releases"`
103+
}
104+
105+
// release maps an archive name to an archive.
106+
type release map[string]archive
107+
108+
// archive contains the self link to an archive and its hash.
109+
type archive struct {
110+
Hash string `json:"hash"`
111+
SelfLink string `json:"selfLink"`
112+
}
113+
114+
func downloadBinaryAssets(ctx context.Context, binaryAssetsDirectory, binaryAssetsVersion, binaryAssetsIndexURL string) (string, string, string, error) {
115+
if binaryAssetsIndexURL == "" {
116+
binaryAssetsIndexURL = DefaultBinaryAssetsIndexURL
117+
}
118+
119+
downloadRootDir := binaryAssetsDirectory
120+
if downloadRootDir == "" {
121+
var err error
122+
if downloadRootDir, err = os.MkdirTemp("", "envtest-binaries-"); err != nil {
123+
return "", "", "", fmt.Errorf("failed to create tmp directory for envtest binaries: %w", err)
124+
}
125+
}
126+
127+
var binaryAssetsIndex *index
128+
if binaryAssetsVersion == "" {
129+
var err error
130+
binaryAssetsIndex, err = getIndex(ctx, binaryAssetsIndexURL)
131+
if err != nil {
132+
return "", "", "", err
133+
}
134+
135+
binaryAssetsVersion, err = latestStableVersionFromIndex(binaryAssetsIndex)
136+
if err != nil {
137+
return "", "", "", err
138+
}
139+
}
140+
141+
// Storing the envtest binaries in a directory structure that is compatible with setup-envtest.
142+
// This makes it possible to share the envtest binaries with setup-envtest if the BinaryAssetsDirectory is set to SetupEnvtestDefaultBinaryAssetsDirectory().
143+
downloadDir := path.Join(downloadRootDir, fmt.Sprintf("%s-%s-%s", strings.TrimPrefix(binaryAssetsVersion, "v"), runtime.GOOS, runtime.GOARCH))
144+
if !fileExists(downloadDir) {
145+
if err := os.Mkdir(downloadDir, 0700); err != nil {
146+
return "", "", "", fmt.Errorf("failed to create directory %q for envtest binaries: %w", downloadDir, err)
147+
}
148+
}
149+
150+
apiServerPath := path.Join(downloadDir, "kube-apiserver")
151+
etcdPath := path.Join(downloadDir, "etcd")
152+
kubectlPath := path.Join(downloadDir, "kubectl")
153+
154+
if fileExists(apiServerPath) && fileExists(etcdPath) && fileExists(kubectlPath) {
155+
// Nothing to do if the binaries already exist.
156+
return apiServerPath, etcdPath, kubectlPath, nil
157+
}
158+
159+
// Get Index if we didn't have to get it above to get the latest stable version.
160+
if binaryAssetsIndex == nil {
161+
var err error
162+
binaryAssetsIndex, err = getIndex(ctx, binaryAssetsIndexURL)
163+
if err != nil {
164+
return "", "", "", err
165+
}
166+
}
167+
168+
buf := &bytes.Buffer{}
169+
if err := downloadBinaryAssetsArchive(ctx, binaryAssetsIndex, binaryAssetsVersion, buf); err != nil {
170+
return "", "", "", err
171+
}
172+
173+
gzStream, err := gzip.NewReader(buf)
174+
if err != nil {
175+
return "", "", "", fmt.Errorf("failed to create gzip reader to extract envtest binaries: %w", err)
176+
}
177+
tarReader := tar.NewReader(gzStream)
178+
179+
var header *tar.Header
180+
for header, err = tarReader.Next(); err == nil; header, err = tarReader.Next() {
181+
if header.Typeflag != tar.TypeReg {
182+
// Skip non-regular file entry in archive.
183+
continue
184+
}
185+
186+
// Just dump all files directly into the download directory, ignoring the prefixed directory paths.
187+
// We also ignore bits for the most part (except for X).
188+
fileName := filepath.Base(header.Name)
189+
perms := 0555 & header.Mode // make sure we're at most r+x
190+
191+
// Setting O_EXCL to get an error if the file already exists.
192+
f, err := os.OpenFile(path.Join(downloadDir, fileName), os.O_RDWR|os.O_CREATE|os.O_EXCL|os.O_TRUNC, os.FileMode(perms))
193+
if err != nil {
194+
if os.IsExist(err) {
195+
// Nothing to do if the file already exists. We assume another process created the file concurrently.
196+
continue
197+
}
198+
return "", "", "", fmt.Errorf("failed to create file %s in directory %s: %w", fileName, downloadDir, err)
199+
}
200+
if err := func() error {
201+
defer f.Close()
202+
if _, err := io.Copy(f, tarReader); err != nil {
203+
return fmt.Errorf("failed to write file %s in directory %s: %w", fileName, downloadDir, err)
204+
}
205+
return nil
206+
}(); err != nil {
207+
return "", "", "", fmt.Errorf("failed to close file %s in directory %s: %w", fileName, downloadDir, err)
208+
}
209+
}
210+
211+
return apiServerPath, etcdPath, kubectlPath, nil
212+
}
213+
214+
func fileExists(path string) bool {
215+
if _, err := os.Stat(path); err == nil {
216+
return true
217+
}
218+
return false
219+
}
220+
221+
func downloadBinaryAssetsArchive(ctx context.Context, index *index, version string, out io.Writer) error {
222+
archives, ok := index.Releases[version]
223+
if !ok {
224+
return fmt.Errorf("failed to find envtest binaries for version %s", version)
225+
}
226+
227+
archiveName := fmt.Sprintf("envtest-%s-%s-%s.tar.gz", version, runtime.GOOS, runtime.GOARCH)
228+
archive, ok := archives[archiveName]
229+
if !ok {
230+
return fmt.Errorf("failed to find envtest binaries for version %s with archiveName %s", version, archiveName)
231+
}
232+
233+
archiveURL, err := url.Parse(archive.SelfLink)
234+
if err != nil {
235+
return fmt.Errorf("failed to parse envtest binaries archive URL %q: %w", archiveURL, err)
236+
}
237+
238+
req, err := http.NewRequestWithContext(ctx, "GET", archiveURL.String(), nil)
239+
if err != nil {
240+
return fmt.Errorf("failed to create request to download %s: %w", archiveURL.String(), err)
241+
}
242+
resp, err := http.DefaultClient.Do(req)
243+
if err != nil {
244+
return fmt.Errorf("failed to download %s: %w", archiveURL.String(), err)
245+
}
246+
defer resp.Body.Close()
247+
248+
if resp.StatusCode != 200 {
249+
return fmt.Errorf("failed to download %s, got status %q", archiveURL.String(), resp.Status)
250+
}
251+
252+
return readBody(resp, out, archiveName, archive.Hash)
253+
}
254+
255+
func latestStableVersionFromIndex(index *index) (string, error) {
256+
if len(index.Releases) == 0 {
257+
return "", fmt.Errorf("failed to find latest stable version from index: index is empty")
258+
}
259+
260+
parsedVersions := []semver.Version{}
261+
for releaseVersion := range index.Releases {
262+
v, err := semver.ParseTolerant(releaseVersion)
263+
if err != nil {
264+
return "", fmt.Errorf("failed to parse version %q: %w", releaseVersion, err)
265+
}
266+
267+
// Filter out pre-releases.
268+
if len(v.Pre) > 0 {
269+
continue
270+
}
271+
272+
parsedVersions = append(parsedVersions, v)
273+
}
274+
275+
if len(parsedVersions) == 0 {
276+
return "", fmt.Errorf("failed to find latest stable version from index: index does not have stable versions")
277+
}
278+
279+
sort.Slice(parsedVersions, func(i, j int) bool {
280+
return parsedVersions[i].GT(parsedVersions[j])
281+
})
282+
return "v" + parsedVersions[0].String(), nil
283+
}
284+
285+
func getIndex(ctx context.Context, indexURL string) (*index, error) {
286+
loc, err := url.Parse(indexURL)
287+
if err != nil {
288+
return nil, fmt.Errorf("unable to parse index URL: %w", err)
289+
}
290+
291+
req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil)
292+
if err != nil {
293+
return nil, fmt.Errorf("unable to construct request to get index: %w", err)
294+
}
295+
296+
resp, err := http.DefaultClient.Do(req)
297+
if err != nil {
298+
return nil, fmt.Errorf("unable to perform request to get index: %w", err)
299+
}
300+
301+
defer resp.Body.Close()
302+
if resp.StatusCode != 200 {
303+
return nil, fmt.Errorf("unable to get index -- got status %q", resp.Status)
304+
}
305+
306+
responseBody, err := io.ReadAll(resp.Body)
307+
if err != nil {
308+
return nil, fmt.Errorf("unable to get index -- unable to read body %w", err)
309+
}
310+
311+
var index index
312+
if err := yaml.Unmarshal(responseBody, &index); err != nil {
313+
return nil, fmt.Errorf("unable to unmarshal index: %w", err)
314+
}
315+
return &index, nil
316+
}
317+
318+
func readBody(resp *http.Response, out io.Writer, archiveName string, expectedHash string) error {
319+
// Stream in chunks to do the checksum
320+
buf := make([]byte, 32*1024) // 32KiB, same as io.Copy
321+
hasher := sha512.New()
322+
323+
for cont := true; cont; {
324+
amt, err := resp.Body.Read(buf)
325+
if err != nil && !errors.Is(err, io.EOF) {
326+
return fmt.Errorf("unable read next chunk of %s: %w", archiveName, err)
327+
}
328+
if amt > 0 {
329+
// checksum never returns errors according to docs
330+
hasher.Write(buf[:amt])
331+
if _, err := out.Write(buf[:amt]); err != nil {
332+
return fmt.Errorf("unable write next chunk of %s: %w", archiveName, err)
333+
}
334+
}
335+
cont = amt > 0 && !errors.Is(err, io.EOF)
336+
}
337+
338+
actualHash := hex.EncodeToString(hasher.Sum(nil))
339+
if actualHash != expectedHash {
340+
return fmt.Errorf("checksum mismatch for %s: %s (computed) != %s (expected)", archiveName, actualHash, expectedHash)
341+
}
342+
343+
return nil
344+
}

0 commit comments

Comments
 (0)