Skip to content

Commit bbc8733

Browse files
authored
Merge pull request #21605 from medyagh/preload_gh_asset
preload: download from github when gcs not available
2 parents de17596 + 7a7a8d7 commit bbc8733

File tree

5 files changed

+383
-133
lines changed

5 files changed

+383
-133
lines changed

pkg/minikube/download/download_test.go

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"time"
2626

2727
"k8s.io/minikube/pkg/minikube/constants"
28+
"k8s.io/minikube/pkg/minikube/localpath"
2829
)
2930

3031
// Force download tests to run in serial.
@@ -33,7 +34,6 @@ func TestDownload(t *testing.T) {
3334
t.Run("PreloadDownloadPreventsMultipleDownload", testPreloadDownloadPreventsMultipleDownload)
3435
t.Run("ImageToCache", testImageToCache)
3536
t.Run("PreloadNotExists", testPreloadNotExists)
36-
t.Run("PreloadChecksumMismatch", testPreloadChecksumMismatch)
3737
t.Run("PreloadExistsCaching", testPreloadExistsCaching)
3838
t.Run("PreloadWithCachedSizeZero", testPreloadWithCachedSizeZero)
3939
}
@@ -48,7 +48,31 @@ func mockSleepDownload(downloadsCounter *int) func(src, dst string) error {
4848
}
4949
}
5050

51+
// point each subtest at an isolated MINIKUBE_HOME, pre-create the preload cache directory,
52+
//
53+
// and automatically restore the global download/preload mocks after each run.
54+
// Applied the helper across all download-related tests
55+
func setupTestMiniHome(t *testing.T) {
56+
t.Helper()
57+
tmpHome := t.TempDir()
58+
t.Setenv(localpath.MinikubeHome, tmpHome)
59+
if err := os.MkdirAll(targetDir(), 0o755); err != nil {
60+
t.Fatalf("failed to create preload cache dir: %v", err)
61+
}
62+
origDownloadMock := DownloadMock
63+
origCheckCache := checkCache
64+
origCheckPreloadExists := checkPreloadExists
65+
origGetChecksumGCS := getChecksumGCS
66+
t.Cleanup(func() {
67+
DownloadMock = origDownloadMock
68+
checkCache = origCheckCache
69+
checkPreloadExists = origCheckPreloadExists
70+
getChecksumGCS = origGetChecksumGCS
71+
})
72+
}
73+
5174
func testBinaryDownloadPreventsMultipleDownload(t *testing.T) {
75+
setupTestMiniHome(t)
5276
downloadNum := 0
5377
DownloadMock = mockSleepDownload(&downloadNum)
5478

@@ -79,6 +103,8 @@ func testBinaryDownloadPreventsMultipleDownload(t *testing.T) {
79103
}
80104

81105
func testPreloadDownloadPreventsMultipleDownload(t *testing.T) {
106+
setupTestMiniHome(t)
107+
82108
downloadNum := 0
83109
DownloadMock = mockSleepDownload(&downloadNum)
84110
f, err := os.CreateTemp("", "preload")
@@ -97,8 +123,7 @@ func testPreloadDownloadPreventsMultipleDownload(t *testing.T) {
97123
return os.Stat(f.Name())
98124
}
99125
checkPreloadExists = func(_, _, _ string, _ ...bool) bool { return true }
100-
getChecksum = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
101-
ensureChecksumValid = func(_, _, _ string, _ []byte) error { return nil }
126+
getChecksumGCS = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
102127

103128
var group sync.WaitGroup
104129
group.Add(2)
@@ -120,13 +145,13 @@ func testPreloadDownloadPreventsMultipleDownload(t *testing.T) {
120145
}
121146

122147
func testPreloadNotExists(t *testing.T) {
148+
setupTestMiniHome(t)
123149
downloadNum := 0
124150
DownloadMock = mockSleepDownload(&downloadNum)
125151

126152
checkCache = func(_ string) (fs.FileInfo, error) { return nil, fmt.Errorf("cache not found") }
127153
checkPreloadExists = func(_, _, _ string, _ ...bool) bool { return false }
128-
getChecksum = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
129-
ensureChecksumValid = func(_, _, _ string, _ []byte) error { return nil }
154+
getChecksumGCS = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
130155

131156
err := Preload(constants.DefaultKubernetesVersion, constants.Docker, "docker")
132157
if err != nil {
@@ -138,27 +163,8 @@ func testPreloadNotExists(t *testing.T) {
138163
}
139164
}
140165

141-
func testPreloadChecksumMismatch(t *testing.T) {
142-
downloadNum := 0
143-
DownloadMock = mockSleepDownload(&downloadNum)
144-
145-
checkCache = func(_ string) (fs.FileInfo, error) { return nil, fmt.Errorf("cache not found") }
146-
checkPreloadExists = func(_, _, _ string, _ ...bool) bool { return true }
147-
getChecksum = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
148-
ensureChecksumValid = func(_, _, _ string, _ []byte) error {
149-
return fmt.Errorf("checksum mismatch")
150-
}
151-
152-
err := Preload(constants.DefaultKubernetesVersion, constants.Docker, "docker")
153-
expectedErrMsg := "checksum mismatch"
154-
if err == nil {
155-
t.Errorf("Expected error when checksum mismatches")
156-
} else if err.Error() != expectedErrMsg {
157-
t.Errorf("Expected error to be %s, got %s", expectedErrMsg, err.Error())
158-
}
159-
}
160-
161166
func testImageToCache(t *testing.T) {
167+
setupTestMiniHome(t)
162168
downloadNum := 0
163169
DownloadMock = mockSleepDownload(&downloadNum)
164170

@@ -184,44 +190,72 @@ func testImageToCache(t *testing.T) {
184190
}
185191

186192
// Validates that preload existence checks correctly caches values retrieved by remote checks.
193+
// testPreloadExistsCaching verifies the caching semantics of PreloadExists when
194+
// the local cache is absent and remote existence checks are required.
195+
// In summary, this test enforces that:
196+
// - PreloadExists performs remote checks only on cache misses.
197+
// - Negative and positive results are cached per (k8sVersion, containerVersion, runtime) key.
198+
// - GitHub is only consulted when GCS reports the preload as not existing.
199+
// - Global state is correctly restored after the test.
187200
func testPreloadExistsCaching(t *testing.T) {
201+
setupTestMiniHome(t)
188202
checkCache = func(_ string) (fs.FileInfo, error) {
189203
return nil, fmt.Errorf("cache not found")
190204
}
191205
doesPreloadExist := false
192-
checkCalled := false
193-
checkRemotePreloadExists = func(_, _ string) bool {
194-
checkCalled = true
206+
gcsCheckCalls := 0
207+
ghCheckCalls := 0
208+
savedGCSCheck := checkRemotePreloadExistsGCS
209+
savedGHCheck := checkRemotePreloadExistsGitHub
210+
preloadStates = make(map[string]map[string]preloadState)
211+
checkRemotePreloadExistsGCS = func(_, _ string) bool {
212+
gcsCheckCalls++
195213
return doesPreloadExist
196214
}
215+
checkRemotePreloadExistsGitHub = func(_, _ string) bool {
216+
ghCheckCalls++
217+
return false
218+
}
219+
t.Cleanup(func() {
220+
checkRemotePreloadExistsGCS = savedGCSCheck
221+
checkRemotePreloadExistsGitHub = savedGHCheck
222+
preloadStates = make(map[string]map[string]preloadState)
223+
})
224+
197225
existence := PreloadExists("v1", "c1", "docker", true)
198-
if existence || !checkCalled {
199-
t.Errorf("Expected preload not to exist and a check to be performed. Existence: %v, Check: %v", existence, checkCalled)
226+
if existence || gcsCheckCalls != 1 || ghCheckCalls != 1 {
227+
t.Errorf("Expected preload not to exist and checks to be performed. Existence: %v, GCS Calls: %d, GH Calls: %d", existence, gcsCheckCalls, ghCheckCalls)
200228
}
201-
checkCalled = false
229+
gcsCheckCalls = 0
230+
ghCheckCalls = 0
202231
existence = PreloadExists("v1", "c1", "docker", true)
203-
if existence || checkCalled {
204-
t.Errorf("Expected preload not to exist and no check to be performed. Existence: %v, Check: %v", existence, checkCalled)
232+
if existence || gcsCheckCalls != 0 || ghCheckCalls != 0 {
233+
t.Errorf("Expected preload not to exist and no checks to be performed. Existence: %v, GCS Calls: %d, GH Calls: %d", existence, gcsCheckCalls, ghCheckCalls)
205234
}
206235
doesPreloadExist = true
207-
checkCalled = false
236+
gcsCheckCalls = 0
237+
ghCheckCalls = 0
208238
existence = PreloadExists("v2", "c1", "docker", true)
209-
if !existence || !checkCalled {
210-
t.Errorf("Expected preload to exist and a check to be performed. Existence: %v, Check: %v", existence, checkCalled)
239+
if !existence || gcsCheckCalls != 1 || ghCheckCalls != 0 {
240+
t.Errorf("Expected preload to exist via GCS. Existence: %v, GCS Calls: %d, GH Calls: %d", existence, gcsCheckCalls, ghCheckCalls)
211241
}
212-
checkCalled = false
242+
gcsCheckCalls = 0
243+
ghCheckCalls = 0
213244
existence = PreloadExists("v2", "c2", "docker", true)
214-
if !existence || !checkCalled {
215-
t.Errorf("Expected preload to exist and a check to be performed. Existence: %v, Check: %v", existence, checkCalled)
245+
if !existence || gcsCheckCalls != 1 || ghCheckCalls != 0 {
246+
t.Errorf("Expected preload to exist via GCS for new runtime. Existence: %v, GCS Calls: %d, GH Calls: %d", existence, gcsCheckCalls, ghCheckCalls)
216247
}
217-
checkCalled = false
248+
gcsCheckCalls = 0
249+
ghCheckCalls = 0
218250
existence = PreloadExists("v2", "c2", "docker", true)
219-
if !existence || checkCalled {
220-
t.Errorf("Expected preload to exist and no check to be performed. Existence: %v, Check: %v", existence, checkCalled)
251+
if !existence || gcsCheckCalls != 0 || ghCheckCalls != 0 {
252+
t.Errorf("Expected preload to exist and no checks to be performed. Existence: %v, GCS Calls: %d, GH Calls: %d", existence, gcsCheckCalls, ghCheckCalls)
221253
}
222254
}
223255

224256
func testPreloadWithCachedSizeZero(t *testing.T) {
257+
setupTestMiniHome(t)
258+
225259
downloadNum := 0
226260
DownloadMock = mockSleepDownload(&downloadNum)
227261
f, err := os.CreateTemp("", "preload")
@@ -231,8 +265,7 @@ func testPreloadWithCachedSizeZero(t *testing.T) {
231265

232266
checkCache = func(_ string) (fs.FileInfo, error) { return os.Stat(f.Name()) }
233267
checkPreloadExists = func(_, _, _ string, _ ...bool) bool { return true }
234-
getChecksum = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
235-
ensureChecksumValid = func(_, _, _ string, _ []byte) error { return nil }
268+
getChecksumGCS = func(_, _ string) ([]byte, error) { return []byte("check"), nil }
236269

237270
if err := Preload(constants.DefaultKubernetesVersion, constants.Docker, "docker"); err != nil {
238271
t.Errorf("Expected no error with cached preload of size zero")

pkg/minikube/download/gh/gh.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors All rights reserved.
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 gh provides helper utilities for interacting with the GitHub API
18+
package gh
19+
20+
import (
21+
"context"
22+
"fmt"
23+
"net/http"
24+
"os"
25+
"strings"
26+
27+
"github.com/google/go-github/v74/github"
28+
"golang.org/x/oauth2"
29+
)
30+
31+
// ReleaseAssets retrieves a GitHub release by tag from org/project.
32+
// Try to not call this too often. preferably cache and re-use. to avoid rate limits.
33+
func ReleaseAssets(org, project, tag string) ([]*github.ReleaseAsset, error) {
34+
ctx := context.Background()
35+
// Use an authenticated client when GITHUB_TOKEN is set to avoid low rate limits.
36+
httpClient := oauthClient(ctx, os.Getenv("GITHUB_TOKEN"))
37+
ghc := github.NewClient(httpClient)
38+
39+
rel, _, err := ghc.Repositories.GetReleaseByTag(ctx, org, project, tag)
40+
return rel.Assets, err
41+
}
42+
43+
// AssetSHA256 returns the SHA-256 digest for the asset with the given name
44+
// from the provided release assets from github API.
45+
// to avoid rate limits. encouraged to call pass results of ReleaseAssets here.
46+
func AssetSHA256(assetName string, assets []*github.ReleaseAsset) ([]byte, error) {
47+
for _, asset := range assets {
48+
if asset.GetName() != assetName {
49+
continue
50+
}
51+
d := asset.GetDigest() // e.g. "sha256:fdcb..."
52+
if d == "" {
53+
return []byte(""), fmt.Errorf("asset %q has no digest; id=%d url=%s", assetName, asset.GetID(), asset.GetBrowserDownloadURL())
54+
}
55+
const prefix = "sha256:"
56+
d = strings.TrimPrefix(d, prefix)
57+
return []byte(d), nil
58+
}
59+
return []byte(""), fmt.Errorf("asset %q not found", assetName)
60+
}
61+
62+
func oauthClient(ctx context.Context, token string) *http.Client {
63+
if token == "" {
64+
return nil // unauthenticated client (lower rate limit)
65+
}
66+
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
67+
return oauth2.NewClient(ctx, ts)
68+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors All rights reserved.
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 gh
18+
19+
import (
20+
"strings"
21+
"testing"
22+
23+
"github.com/google/go-github/v74/github"
24+
)
25+
26+
func TestAssetSHA256(t *testing.T) {
27+
t.Run("found_with_sha256_prefix", func(t *testing.T) {
28+
assets := []*github.ReleaseAsset{
29+
{
30+
Name: github.Ptr("minikube-linux-amd64"),
31+
Digest: github.Ptr("sha256:abcdef123456"),
32+
ID: github.Ptr(int64(101)),
33+
BrowserDownloadURL: github.Ptr("http://example/minikube-linux-amd64"),
34+
},
35+
}
36+
got, err := AssetSHA256("minikube-linux-amd64", assets)
37+
if err != nil {
38+
t.Fatalf("unexpected error: %v", err)
39+
}
40+
if string(got) != "abcdef123456" {
41+
t.Fatalf("expected digest %q, got %q", "abcdef123456", string(got))
42+
}
43+
})
44+
45+
t.Run("found_without_prefix", func(t *testing.T) {
46+
assets := []*github.ReleaseAsset{
47+
{
48+
Name: github.Ptr("minikube-darwin-arm64"),
49+
Digest: github.Ptr("1234abcd"),
50+
ID: github.Ptr(int64(102)),
51+
BrowserDownloadURL: github.Ptr("http://example/minikube-darwin-arm64"),
52+
},
53+
}
54+
got, err := AssetSHA256("minikube-darwin-arm64", assets)
55+
if err != nil {
56+
t.Fatalf("unexpected error: %v", err)
57+
}
58+
if string(got) != "1234abcd" {
59+
t.Fatalf("expected digest %q, got %q", "1234abcd", string(got))
60+
}
61+
})
62+
63+
t.Run("asset_missing_digest", func(t *testing.T) {
64+
assets := []*github.ReleaseAsset{
65+
{
66+
Name: github.Ptr("minikube-windows-amd64.exe"),
67+
Digest: github.Ptr(""),
68+
ID: github.Ptr(int64(103)),
69+
BrowserDownloadURL: github.Ptr("http://example/minikube-windows-amd64.exe"),
70+
},
71+
}
72+
_, err := AssetSHA256("minikube-windows-amd64.exe", assets)
73+
if err == nil {
74+
t.Fatalf("expected error, got nil")
75+
}
76+
if !strings.Contains(err.Error(), "has no digest") {
77+
t.Fatalf("unexpected error message: %v", err)
78+
}
79+
})
80+
81+
t.Run("asset_not_found", func(t *testing.T) {
82+
assets := []*github.ReleaseAsset{
83+
{
84+
Name: github.Ptr("unrelated"),
85+
Digest: github.Ptr("sha256:deadbeef"),
86+
ID: github.Ptr(int64(104)),
87+
BrowserDownloadURL: github.Ptr("http://example/unrelated"),
88+
},
89+
}
90+
_, err := AssetSHA256("missing", assets)
91+
if err == nil {
92+
t.Fatalf("expected error, got nil")
93+
}
94+
if !strings.Contains(err.Error(), `asset "missing" not found`) {
95+
t.Fatalf("unexpected error message: %v", err)
96+
}
97+
})
98+
}

0 commit comments

Comments
 (0)