Skip to content

Commit 4a70d59

Browse files
committed
envtest: add option to download binaries, bump envtest to v1.32.0
Signed-off-by: Stefan Büringer [email protected]
1 parent 9d8d219 commit 4a70d59

File tree

4 files changed

+317
-4
lines changed

4 files changed

+317
-4
lines changed

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

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

pkg/envtest/server.go

+30-3
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,23 @@ type Environment struct {
147147
// values are merged.
148148
CRDDirectoryPaths []string
149149

150+
// DownloadBinaryAssets indicates that the envtest binaries should be downloaded.
151+
// If this field is set:
152+
// * DownloadBinaryAssetsVersion must also be set
153+
// * If BinaryAssetsDirectory is also set, it is used to store the downloaded binaries,
154+
// otherwise a tmp directory is created.
155+
DownloadBinaryAssets bool
156+
157+
// DownloadBinaryAssetsVersion is the version of envtest binaries to download.
158+
DownloadBinaryAssetsVersion string
159+
160+
// DownloadBinaryAssetsIndexURL is the index used to discover envtest binaries to download.
161+
// Defaults to https://raw.githubusercontent.com/kubernetes-sigs/controller-tools/HEAD/envtest-releases.yaml.
162+
DownloadBinaryAssetsIndexURL string
163+
150164
// BinaryAssetsDirectory is the path where the binaries required for the envtest are
151165
// located in the local environment. This field can be overridden by setting KUBEBUILDER_ASSETS.
166+
// Set this field to SetupEnvtestDefaultBinaryAssetsDirectory() to share binaries with setup-envtest.
152167
BinaryAssetsDirectory string
153168

154169
// UseExistingCluster indicates that this environments should use an
@@ -233,9 +248,21 @@ func (te *Environment) Start() (*rest.Config, error) {
233248
}
234249
}
235250

236-
apiServer.Path = process.BinPathFinder("kube-apiserver", te.BinaryAssetsDirectory)
237-
te.ControlPlane.Etcd.Path = process.BinPathFinder("etcd", te.BinaryAssetsDirectory)
238-
te.ControlPlane.KubectlPath = process.BinPathFinder("kubectl", te.BinaryAssetsDirectory)
251+
if te.DownloadBinaryAssets {
252+
apiServerPath, etcdPath, kubectlPath, err := downloadBinaryAssets(context.TODO(),
253+
te.BinaryAssetsDirectory, te.DownloadBinaryAssetsVersion, te.DownloadBinaryAssetsIndexURL)
254+
if err != nil {
255+
return nil, err
256+
}
257+
258+
apiServer.Path = apiServerPath
259+
te.ControlPlane.Etcd.Path = etcdPath
260+
te.ControlPlane.KubectlPath = kubectlPath
261+
} else {
262+
apiServer.Path = process.BinPathFinder("kube-apiserver", te.BinaryAssetsDirectory)
263+
te.ControlPlane.Etcd.Path = process.BinPathFinder("etcd", te.BinaryAssetsDirectory)
264+
te.ControlPlane.KubectlPath = process.BinPathFinder("kubectl", te.BinaryAssetsDirectory)
265+
}
239266

240267
if err := te.defaultTimeouts(); err != nil {
241268
return nil, fmt.Errorf("failed to default controlplane timeouts: %w", err)

0 commit comments

Comments
 (0)