Skip to content

Commit 44f089d

Browse files
authored
test: Fail Fast if docker hub rate limited (#22081)
* test addons: add rate limit check for Docker Hub in TestAddons * move docker rate limit check to detect package * move to helpers_test * replace skipAddonsIfDockerHubRateLimited with FailFastDockerHubRateLimited in TestAddons * check both lowercase and upper for headers * increase test timeout from 7m to 10m in functional test workflow
1 parent 792fc03 commit 44f089d

File tree

5 files changed

+224
-2
lines changed

5 files changed

+224
-2
lines changed

.github/workflows/functional_test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ jobs:
8787
driver: none
8888
cruntime: docker
8989
os: ubuntu-22.04
90-
test-timeout: 9m
91-
- name: qemu-docker-macos-15-x86_64
90+
test-timeout: 10m
91+
- name: qemu-docker-macos-13-x86_64
9292
driver: qemu
9393
cruntime: docker
9494
os: macos-15-intel

pkg/minikube/detect/dockerhub.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
Copyright 2024 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 detect
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"net/http"
25+
"net/url"
26+
"strconv"
27+
"strings"
28+
"time"
29+
)
30+
31+
const (
32+
dockerHubAuthURL = "https://auth.docker.io/token"
33+
dockerHubRegistryURL = "https://registry-1.docker.io"
34+
dockerHubPreviewRepo = "ratelimitpreview/test"
35+
dockerHubPreviewScope = "repository:ratelimitpreview/test:pull"
36+
)
37+
38+
// DockerHubRateLimitRemaining returns the remaining Docker Hub pulls for the calling IP.
39+
// Based on https://docs.docker.com/docker-hub/usage/pulls/#view-pull-rate-and-limit
40+
// calling this func will NOT reduce number of remaining pulls.
41+
func DockerHubRateLimitRemaining(ctx context.Context) (int, error) {
42+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
43+
defer cancel()
44+
45+
token, err := dockerHubRateLimitToken(ctx)
46+
if err != nil {
47+
return 0, err
48+
}
49+
50+
client := &http.Client{Timeout: 10 * time.Second}
51+
manifestURL := fmt.Sprintf("%s/v2/%s/manifests/latest", dockerHubRegistryURL, dockerHubPreviewRepo)
52+
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
53+
if err != nil {
54+
return 0, fmt.Errorf("building rate limit request: %w", err)
55+
}
56+
req.Header.Set("Authorization", "Bearer "+token)
57+
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
58+
59+
resp, err := client.Do(req)
60+
if err != nil {
61+
return 0, fmt.Errorf("querying rate limit: %w", err)
62+
}
63+
resp.Body.Close()
64+
65+
if resp.StatusCode == http.StatusTooManyRequests {
66+
return 0, fmt.Errorf("docker hub rate limit exceeded (HTTP 429)")
67+
}
68+
69+
// Go canonicalizes header keys, so both ratelimit-remaining and RateLimit-Remaining work.
70+
remainingHeader := resp.Header.Get("RateLimit-Remaining")
71+
if remainingHeader == "" {
72+
remainingHeader = resp.Header.Get("ratelimit-remaining")
73+
}
74+
if remainingHeader == "" {
75+
return 0, errors.New("docker hub RateLimit-Remaining header missing")
76+
}
77+
78+
remaining, err := parseDockerHubRemaining(remainingHeader)
79+
if err != nil {
80+
return 0, err
81+
}
82+
83+
return remaining, nil
84+
}
85+
86+
// dockerHubRateLimitToken retrieves an authentication token from Docker Hub's authentication service.
87+
// for non-logged in dockers it will still return a token but with lower rate limits.
88+
func dockerHubRateLimitToken(ctx context.Context) (string, error) {
89+
values := url.Values{}
90+
values.Set("service", "registry.docker.io")
91+
values.Set("scope", dockerHubPreviewScope)
92+
93+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, dockerHubAuthURL, nil)
94+
if err != nil {
95+
return "", fmt.Errorf("building auth request: %w", err)
96+
}
97+
req.URL.RawQuery = values.Encode()
98+
99+
resp, err := http.DefaultClient.Do(req)
100+
if err != nil {
101+
return "", fmt.Errorf("auth request failed: %w", err)
102+
}
103+
defer resp.Body.Close()
104+
105+
if resp.StatusCode != http.StatusOK {
106+
return "", fmt.Errorf("auth request returned %d", resp.StatusCode)
107+
}
108+
109+
var parsed struct {
110+
Token string `json:"token"`
111+
}
112+
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
113+
return "", fmt.Errorf("decoding auth response: %w", err)
114+
}
115+
if parsed.Token == "" {
116+
return "", errors.New("empty token from Docker Hub auth response")
117+
}
118+
return parsed.Token, nil
119+
}
120+
121+
// parseDockerHubRemaining extracts the remaining API rate limit count from a Docker Hub
122+
// RateLimit-Remaining header value.
123+
func parseDockerHubRemaining(headerVal string) (int, error) {
124+
separators := func(r rune) bool {
125+
return r == ';' || r == ','
126+
}
127+
for _, part := range strings.FieldsFunc(headerVal, separators) {
128+
part = strings.TrimSpace(part)
129+
if part == "" {
130+
continue
131+
}
132+
if remaining, err := strconv.Atoi(part); err == nil {
133+
return remaining, nil
134+
}
135+
}
136+
return 0, fmt.Errorf("unable to find integer value in RateLimit-Remaining header %q", headerVal)
137+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Copyright 2024 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 detect
18+
19+
import "testing"
20+
21+
func TestParseDockerHubRemaining(t *testing.T) {
22+
tests := []struct {
23+
name string
24+
header string
25+
want int
26+
shouldFail bool
27+
}{
28+
{
29+
name: "ExampleFromDocs",
30+
header: "100;w=21600",
31+
want: 100,
32+
},
33+
{
34+
name: "CommaSeparated",
35+
header: "5000;w=60,burst=5000",
36+
want: 5000,
37+
},
38+
{
39+
name: "Whitespace",
40+
header: " 42 ; w=60 ",
41+
want: 42,
42+
},
43+
{
44+
name: "NoNumericValue",
45+
header: "w=21600",
46+
shouldFail: true,
47+
},
48+
}
49+
50+
for _, tc := range tests {
51+
tc := tc
52+
t.Run(tc.name, func(t *testing.T) {
53+
got, err := parseDockerHubRemaining(tc.header)
54+
if tc.shouldFail {
55+
if err == nil {
56+
t.Fatalf("parseDockerHubRemaining(%q) expected error, got value %d", tc.header, got)
57+
}
58+
return
59+
}
60+
if err != nil {
61+
t.Fatalf("parseDockerHubRemaining(%q) unexpected error: %v", tc.header, err)
62+
}
63+
if got != tc.want {
64+
t.Fatalf("parseDockerHubRemaining(%q) = %d, want %d", tc.header, got, tc.want)
65+
}
66+
})
67+
}
68+
}

test/integration/addons_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import (
4848

4949
// TestAddons tests addons that require no special environment in parallel
5050
func TestAddons(t *testing.T) {
51+
FailFastDockerHubRateLimited(t)
52+
5153
profile := UniqueProfileName("addons")
5254
ctx, cancel := context.WithTimeout(context.Background(), Minutes(40))
5355
defer Cleanup(t, profile, cancel)

test/integration/helpers_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
4545
"k8s.io/apimachinery/pkg/util/wait"
4646
"k8s.io/minikube/pkg/kapi"
47+
"k8s.io/minikube/pkg/minikube/detect"
4748
)
4849

4950
// RunResult stores the result of an cmd.Run call
@@ -704,3 +705,17 @@ func CopyDir(src, dst string) error {
704705

705706
return nil
706707
}
708+
709+
// FailFast proactively stops the addon suite when Docker Hub is rate limited.
710+
func FailFastDockerHubRateLimited(t *testing.T) {
711+
t.Helper()
712+
remaining, err := detect.DockerHubRateLimitRemaining(t.Context())
713+
if err != nil {
714+
t.Logf("unable to check Docker Hub rate limit (continuing): %v", err)
715+
return
716+
}
717+
718+
if remaining <= 0 {
719+
t.Fatalf("failing fast: Docker Hub rate limit reached (remaining=%d)", remaining)
720+
}
721+
}

0 commit comments

Comments
 (0)