mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-24 02:58:42 +09:00
Anonymous Hub pulls trip the 100/6h IP cap on shared CI runners, turning into singleton FAIL reports whenever the runner egress IP crosses the quota. Route every pull through Docker Hub credentials when present, and retry transient errors with backoff. tsic and hi use the same helper so both surfaces honour ~/.docker/config.json and the GHA secrets.
172 lines
4.2 KiB
Go
172 lines
4.2 KiB
Go
package dockertestutil
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v5"
|
|
"github.com/ory/dockertest/v3"
|
|
"github.com/ory/dockertest/v3/docker"
|
|
)
|
|
|
|
const dockerHubServer = "https://index.docker.io/v1/"
|
|
|
|
type CredentialSource string
|
|
|
|
const (
|
|
CredentialSourceEnv CredentialSource = "env"
|
|
CredentialSourceConfig CredentialSource = "config"
|
|
CredentialSourceAnonymous CredentialSource = "anonymous"
|
|
)
|
|
|
|
// Credentials resolves Docker Hub credentials from
|
|
// DOCKERHUB_USERNAME/DOCKERHUB_TOKEN, then ~/.docker/config.json, then
|
|
// anonymous. The Docker Go SDKs do not read config.json on their own.
|
|
func Credentials() (string, string, CredentialSource) {
|
|
if u := os.Getenv("DOCKERHUB_USERNAME"); u != "" {
|
|
return u, os.Getenv("DOCKERHUB_TOKEN"), CredentialSourceEnv
|
|
}
|
|
|
|
user, pass, ok := credentialsFromConfig()
|
|
if ok {
|
|
return user, pass, CredentialSourceConfig
|
|
}
|
|
|
|
return "", "", CredentialSourceAnonymous
|
|
}
|
|
|
|
// AuthConfiguration returns Docker Hub auth for the dockertest pool.
|
|
func AuthConfiguration() docker.AuthConfiguration {
|
|
u, p, _ := Credentials()
|
|
|
|
return docker.AuthConfiguration{
|
|
Username: u,
|
|
Password: p,
|
|
ServerAddress: dockerHubServer,
|
|
}
|
|
}
|
|
|
|
// RegistryAuth returns base64-encoded credentials for the modern
|
|
// Docker SDK's image.PullOptions{RegistryAuth: ...}, or "" when none.
|
|
func RegistryAuth() (string, error) {
|
|
u, p, _ := Credentials()
|
|
if u == "" && p == "" {
|
|
return "", nil
|
|
}
|
|
|
|
auth := struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}{Username: u, Password: p}
|
|
|
|
b, err := json.Marshal(auth) //nolint:gosec // G117: password field holds the Docker Hub token, intentional
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshalling docker auth: %w", err)
|
|
}
|
|
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// PullWithAuth ensures imageRef is local, pulling with auth and
|
|
// retrying transient errors when it is not.
|
|
func PullWithAuth(pool *dockertest.Pool, imageRef string) error {
|
|
if img, _ := pool.Client.InspectImage(imageRef); img != nil {
|
|
return nil
|
|
}
|
|
|
|
repo, tag := splitImageRef(imageRef)
|
|
auth := AuthConfiguration()
|
|
|
|
_, err := backoff.Retry(
|
|
context.Background(),
|
|
func() (struct{}, error) {
|
|
err := pool.Client.PullImage(docker.PullImageOptions{
|
|
Repository: repo,
|
|
Tag: tag,
|
|
}, auth)
|
|
if err == nil {
|
|
return struct{}{}, nil
|
|
}
|
|
|
|
if isPermanentPullError(err) {
|
|
return struct{}{}, backoff.Permanent(err)
|
|
}
|
|
|
|
return struct{}{}, fmt.Errorf("pulling %s: %w", imageRef, err)
|
|
},
|
|
backoff.WithBackOff(backoff.NewExponentialBackOff()),
|
|
backoff.WithMaxElapsedTime(60*time.Second),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("pulling %s with auth (source=%s): %w", imageRef, AuthConfiguration().ServerAddress, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func splitImageRef(ref string) (string, string) {
|
|
if i := strings.LastIndex(ref, ":"); i >= 0 {
|
|
return ref[:i], ref[i+1:]
|
|
}
|
|
|
|
return ref, "latest"
|
|
}
|
|
|
|
func isPermanentPullError(err error) bool {
|
|
msg := strings.ToLower(err.Error())
|
|
|
|
return strings.Contains(msg, "manifest unknown") ||
|
|
strings.Contains(msg, "manifest not found") ||
|
|
strings.Contains(msg, "repository does not exist") ||
|
|
strings.Contains(msg, "name unknown") ||
|
|
strings.Contains(msg, "no such image")
|
|
}
|
|
|
|
// credentialsFromConfig reads the Hub entry from ~/.docker/config.json.
|
|
// Credential helpers (osxkeychain etc.) are not supported; use env vars.
|
|
func credentialsFromConfig() (string, string, bool) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
|
|
raw, err := os.ReadFile(filepath.Join(home, ".docker", "config.json"))
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
|
|
var cfg struct {
|
|
Auths map[string]struct {
|
|
Auth string `json:"auth"`
|
|
} `json:"auths"`
|
|
}
|
|
|
|
err = json.Unmarshal(raw, &cfg)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
|
|
entry, found := cfg.Auths[dockerHubServer]
|
|
if !found || entry.Auth == "" {
|
|
return "", "", false
|
|
}
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(entry.Auth)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
|
|
parts := strings.SplitN(string(decoded), ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", false
|
|
}
|
|
|
|
return parts[0], parts[1], true
|
|
}
|