mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
cli/command: test credential precedence with Docker Desktop
Check that the Docker Desktop session is used for Docker Hub and dhi.io, that other registries use the configuration file, and that DOCKER_AUTH_CONFIG takes precedence over Docker Desktop. TestRetrieveAuthTokenFromImage disables the Docker Desktop lookup. Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
This commit is contained in:
@@ -1,404 +0,0 @@
|
||||
//go:build go1.26
|
||||
|
||||
package secretsengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
seclient "github.com/docker/secrets-engine/client"
|
||||
"github.com/docker/secrets-engine/client/dockerhub"
|
||||
"github.com/docker/secrets-engine/x/api/resolver"
|
||||
"github.com/docker/secrets-engine/x/api/resolver/v1/resolverv1connect"
|
||||
"github.com/docker/secrets-engine/x/secrets"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
const benchSocketEnv = "SECRETSENGINE_BENCH_SOCKET"
|
||||
|
||||
type fakeResolver struct {
|
||||
latency time.Duration
|
||||
values map[string][]byte
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *fakeResolver) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) {
|
||||
r.calls.Add(1)
|
||||
if r.latency > 0 {
|
||||
select {
|
||||
case <-time.After(r.latency):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
var envelopes []secrets.Envelope
|
||||
for key, value := range r.values {
|
||||
id := secrets.MustParseID(key)
|
||||
if pattern.Match(id) {
|
||||
envelopes = append(envelopes, secrets.Envelope{ID: id, Value: value, ResolvedAt: time.Now()})
|
||||
}
|
||||
}
|
||||
if len(envelopes) == 0 {
|
||||
return nil, secrets.ErrNotFound
|
||||
}
|
||||
return envelopes, nil
|
||||
}
|
||||
|
||||
func fakeAccessToken() string {
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
return enc([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + enc([]byte(`{"sub":"bench"}`)) + "." + enc([]byte("signature"))
|
||||
}
|
||||
|
||||
func signedIn(tb testing.TB, expiresAt time.Time) map[string][]byte {
|
||||
tb.Helper()
|
||||
const username = "hubuser"
|
||||
userID := "docker/auth/hub/" + username
|
||||
profile, err := json.Marshal(dockerhub.Profile{UserID: userID, Username: username, Email: username + "@example.com"})
|
||||
assert.NilError(tb, err)
|
||||
s := dockerhub.UserSession{AccessToken: fakeAccessToken()}
|
||||
s.Claims.Username = username
|
||||
s.Claims.ExpiresAt = &dockerhub.NumericDate{Time: expiresAt}
|
||||
userSession, err := json.Marshal(s)
|
||||
assert.NilError(tb, err)
|
||||
return map[string][]byte{
|
||||
"docker/auth/metadata/hub/default": profile,
|
||||
userID: userSession,
|
||||
}
|
||||
}
|
||||
|
||||
var socketDirs atomic.Int64
|
||||
|
||||
func socketDir(tb testing.TB) string {
|
||||
tb.Helper()
|
||||
dir := filepath.Join(os.TempDir(), fmt.Sprintf("se-%d-%d", os.Getpid(), socketDirs.Add(1)))
|
||||
assert.NilError(tb, os.Mkdir(dir, 0o700))
|
||||
tb.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
type fakeEngine struct {
|
||||
socketPath string
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[net.Conn]struct{}
|
||||
}
|
||||
|
||||
func startEngine(tb testing.TB, r *fakeResolver) *fakeEngine {
|
||||
tb.Helper()
|
||||
e := &fakeEngine{
|
||||
socketPath: filepath.Join(socketDir(tb), "engine.sock"),
|
||||
conns: map[net.Conn]struct{}{},
|
||||
}
|
||||
ln, err := net.Listen("unix", e.socketPath)
|
||||
assert.NilError(tb, err)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(resolverv1connect.NewResolverServiceHandler(resolver.NewResolverHandler(r)))
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ConnState: e.track}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
tb.Cleanup(func() { _ = srv.Close() })
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *fakeEngine) track(c net.Conn, state http.ConnState) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
switch state {
|
||||
case http.StateNew:
|
||||
e.conns[c] = struct{}{}
|
||||
case http.StateClosed, http.StateHijacked:
|
||||
delete(e.conns, c)
|
||||
case http.StateActive, http.StateIdle:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *fakeEngine) dropConnections() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for c := range e.conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
clear(e.conns)
|
||||
}
|
||||
|
||||
func startHungEngine(tb testing.TB) string {
|
||||
tb.Helper()
|
||||
socketPath := filepath.Join(socketDir(tb), "engine.sock")
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
assert.NilError(tb, err)
|
||||
var mu sync.Mutex
|
||||
var held []net.Conn
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
held = append(held, c)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
tb.Cleanup(func() {
|
||||
_ = ln.Close()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, c := range held {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
return socketPath
|
||||
}
|
||||
|
||||
func staleSocket(tb testing.TB) string {
|
||||
tb.Helper()
|
||||
socketPath := filepath.Join(socketDir(tb), "engine.sock")
|
||||
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
|
||||
assert.NilError(tb, err)
|
||||
ln.SetUnlinkOnClose(false)
|
||||
assert.NilError(tb, ln.Close())
|
||||
_, err = os.Lstat(socketPath)
|
||||
assert.NilError(tb, err)
|
||||
return socketPath
|
||||
}
|
||||
|
||||
func socketSession(socketPath string, timeout time.Duration) *session {
|
||||
return &session{
|
||||
name: "Docker Hub",
|
||||
connect: func() (hubSessions, error) {
|
||||
return connect(socketPath)
|
||||
},
|
||||
timeout: timeout,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSignedIn(t *testing.T) {
|
||||
r := &fakeResolver{values: signedIn(t, time.Now().Add(time.Hour))}
|
||||
e := startEngine(t, r)
|
||||
p := &provider{hub: socketSession(e.socketPath, lookupTimeout)}
|
||||
|
||||
got, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
assert.Equal(t, got.Username, "hubuser")
|
||||
assert.Equal(t, got.Password, fakeAccessToken())
|
||||
assert.Equal(t, got.ServerAddress, indexServer)
|
||||
assert.Equal(t, got.IdentityToken, "")
|
||||
|
||||
got, ok = p.authConfig("dhi.io")
|
||||
assert.Assert(t, ok)
|
||||
assert.Equal(t, got.ServerAddress, dhiRegistryHost)
|
||||
assert.Equal(t, r.calls.Load(), int64(2), "the profile and the session are read once, then cached")
|
||||
}
|
||||
|
||||
func TestEngineSignedOut(t *testing.T) {
|
||||
r := &fakeResolver{}
|
||||
e := startEngine(t, r)
|
||||
p := &provider{hub: socketSession(e.socketPath, lookupTimeout)}
|
||||
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
assert.Equal(t, r.calls.Load(), int64(1))
|
||||
}
|
||||
|
||||
func TestMissingSocketFallsBackImmediately(t *testing.T) {
|
||||
s := socketSession(filepath.Join(socketDir(t), "engine.sock"), lookupTimeout)
|
||||
|
||||
start := time.Now()
|
||||
_, ok := s.auth()
|
||||
assert.Assert(t, !ok)
|
||||
assert.Assert(t, time.Since(start) < time.Second)
|
||||
assert.Assert(t, s.hub == nil, "no client is created without a socket")
|
||||
}
|
||||
|
||||
func TestStaleSocketFallsBackImmediately(t *testing.T) {
|
||||
s := socketSession(staleSocket(t), lookupTimeout)
|
||||
|
||||
start := time.Now()
|
||||
_, ok := s.auth()
|
||||
assert.Assert(t, !ok)
|
||||
assert.Assert(t, time.Since(start) < time.Second)
|
||||
}
|
||||
|
||||
func TestHungEngineIsBounded(t *testing.T) {
|
||||
const timeout = 200 * time.Millisecond
|
||||
s := socketSession(startHungEngine(t), timeout)
|
||||
|
||||
start := time.Now()
|
||||
_, ok := s.auth()
|
||||
elapsed := time.Since(start)
|
||||
assert.Assert(t, !ok)
|
||||
assert.Assert(t, elapsed >= timeout, elapsed)
|
||||
assert.Assert(t, elapsed < timeout+2*time.Second, elapsed)
|
||||
}
|
||||
|
||||
func benchmarkColdLookup(b *testing.B, socketPath string, e *fakeEngine, want bool) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
s := socketSession(socketPath, lookupTimeout)
|
||||
if _, ok := s.auth(); ok != want {
|
||||
b.Fatalf("got a Desktop session: %t, want %t", ok, want)
|
||||
}
|
||||
if e != nil {
|
||||
b.StopTimer()
|
||||
e.dropConnections()
|
||||
b.StartTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAuthConfig(b *testing.B) {
|
||||
b.Run("other-registry", func(b *testing.B) {
|
||||
p := &provider{hub: socketSession(filepath.Join(socketDir(b), "engine.sock"), lookupTimeout)}
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
if _, ok := p.authConfig("ghcr.io"); ok {
|
||||
b.Fatal("unexpected Desktop session")
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("missing-socket", func(b *testing.B) {
|
||||
benchmarkColdLookup(b, filepath.Join(socketDir(b), "engine.sock"), nil, false)
|
||||
})
|
||||
b.Run("missing-socket-without-stat", func(b *testing.B) {
|
||||
socketPath := filepath.Join(socketDir(b), "engine.sock")
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
c, err := seclient.New(seclient.WithSocketPath(socketPath), seclient.WithTimeout(lookupTimeout))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if _, err := c.HubAuth().GetDefaultSession(context.Background()); err == nil {
|
||||
b.Fatal("unexpected Desktop session")
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("stale-socket", func(b *testing.B) {
|
||||
benchmarkColdLookup(b, staleSocket(b), nil, false)
|
||||
})
|
||||
b.Run("signed-out", func(b *testing.B) {
|
||||
e := startEngine(b, &fakeResolver{})
|
||||
benchmarkColdLookup(b, e.socketPath, e, false)
|
||||
})
|
||||
b.Run("signed-in", func(b *testing.B) {
|
||||
e := startEngine(b, &fakeResolver{values: signedIn(b, time.Now().Add(time.Hour))})
|
||||
benchmarkColdLookup(b, e.socketPath, e, true)
|
||||
})
|
||||
b.Run("signed-in-10ms-engine", func(b *testing.B) {
|
||||
e := startEngine(b, &fakeResolver{latency: 10 * time.Millisecond, values: signedIn(b, time.Now().Add(time.Hour))})
|
||||
benchmarkColdLookup(b, e.socketPath, e, true)
|
||||
})
|
||||
b.Run("signed-in-refetch", func(b *testing.B) {
|
||||
e := startEngine(b, &fakeResolver{values: signedIn(b, time.Now().Add(time.Hour))})
|
||||
s := socketSession(e.socketPath, lookupTimeout)
|
||||
if _, ok := s.auth(); !ok {
|
||||
b.Fatal("no Desktop session")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
s.cached = nil
|
||||
if _, ok := s.auth(); !ok {
|
||||
b.Fatal("no Desktop session")
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("signed-in-cached", func(b *testing.B) {
|
||||
e := startEngine(b, &fakeResolver{values: signedIn(b, time.Now().Add(time.Hour))})
|
||||
s := socketSession(e.socketPath, lookupTimeout)
|
||||
if _, ok := s.auth(); !ok {
|
||||
b.Fatal("no Desktop session")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
if _, ok := s.auth(); !ok {
|
||||
b.Fatal("no Desktop session")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type trackingDialer struct {
|
||||
mu sync.Mutex
|
||||
conns []net.Conn
|
||||
}
|
||||
|
||||
func (d *trackingDialer) dial(socketPath string) func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var nd net.Dialer
|
||||
c, err := nd.DialContext(ctx, "unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.conns = append(d.conns, c)
|
||||
d.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *trackingDialer) closeAll() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for _, c := range d.conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
d.conns = nil
|
||||
}
|
||||
|
||||
func BenchmarkExternalEngine(b *testing.B) {
|
||||
socketPath := os.Getenv(benchSocketEnv)
|
||||
if socketPath == "" {
|
||||
b.Skipf("%s is not set", benchSocketEnv)
|
||||
}
|
||||
b.Run("cold", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
var dialer trackingDialer
|
||||
s := &session{
|
||||
name: "Docker Hub",
|
||||
connect: func() (hubSessions, error) {
|
||||
c, err := seclient.New(seclient.WithDialContext(dialer.dial(socketPath)), seclient.WithTimeout(lookupTimeout))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.HubAuth(), nil
|
||||
},
|
||||
timeout: lookupTimeout,
|
||||
now: time.Now,
|
||||
}
|
||||
_, ok := s.auth()
|
||||
b.StopTimer()
|
||||
dialer.closeAll()
|
||||
if !ok {
|
||||
b.Fatal("no Docker Hub session from the engine at " + socketPath)
|
||||
}
|
||||
b.StartTimer()
|
||||
}
|
||||
})
|
||||
b.Run("refetch", func(b *testing.B) {
|
||||
s := socketSession(socketPath, lookupTimeout)
|
||||
if _, ok := s.auth(); !ok {
|
||||
b.Fatal("no Docker Hub session from the engine at " + socketPath)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
s.cached = nil
|
||||
if _, ok := s.auth(); !ok {
|
||||
b.Fatal("no Docker Hub session from the engine at " + socketPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/internal/registry"
|
||||
seclient "github.com/docker/secrets-engine/client"
|
||||
"github.com/docker/secrets-engine/client/dockerhub"
|
||||
"github.com/docker/secrets-engine/x/api"
|
||||
@@ -25,10 +25,6 @@ const (
|
||||
sessionExpirySkew = 30 * time.Second
|
||||
sessionTTL = time.Minute
|
||||
retryInterval = time.Minute
|
||||
|
||||
indexServer = "https://index.docker.io/v1/"
|
||||
dhiRegistryHost = "dhi.io"
|
||||
stagingRegistryHost = "registry-1-stage.docker.io"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,57 +32,62 @@ var (
|
||||
errSessionExpired = errors.New("the session has expired (not signed in to Docker Desktop?)")
|
||||
)
|
||||
|
||||
type hubSessions interface {
|
||||
type HubClient interface {
|
||||
GetDefaultSession(ctx context.Context) (dockerhub.UserSession, error)
|
||||
GetDefaultProfile(ctx context.Context) (dockerhub.Profile, error)
|
||||
}
|
||||
|
||||
var _ hubSessions = dockerhub.ClientAuth(nil)
|
||||
var _ HubClient = dockerhub.ClientAuth(nil)
|
||||
|
||||
type provider struct {
|
||||
hub *session
|
||||
staging *session
|
||||
type AuthProvider interface {
|
||||
GetAuthConfig(registryHostname string) (types.AuthConfig, error)
|
||||
}
|
||||
|
||||
var shared = &provider{
|
||||
hub: newSession("Docker Hub"),
|
||||
staging: newSession("Docker Hub staging", dockerhub.Staging()),
|
||||
type Provider struct {
|
||||
fallback AuthProvider
|
||||
hub *session
|
||||
staging *session
|
||||
}
|
||||
|
||||
var disabled atomic.Bool
|
||||
|
||||
func Disable() {
|
||||
disabled.Store(true)
|
||||
}
|
||||
|
||||
func AuthConfig(registryHostname string) (types.AuthConfig, bool) {
|
||||
if disabled.Load() {
|
||||
return types.AuthConfig{}, false
|
||||
func New(fallback AuthProvider, hub, staging HubClient) *Provider {
|
||||
return &Provider{
|
||||
fallback: fallback,
|
||||
hub: newSession("Docker Hub", hub),
|
||||
staging: newSession("Docker Hub staging", staging),
|
||||
}
|
||||
return shared.authConfig(registryHostname)
|
||||
}
|
||||
|
||||
func (p *provider) authConfig(registryHostname string) (types.AuthConfig, bool) {
|
||||
s, serverAddress := p.sessionFor(registryHostname)
|
||||
if s == nil {
|
||||
return types.AuthConfig{}, false
|
||||
var (
|
||||
desktopHubSession = newDesktopSession("Docker Hub")
|
||||
desktopStagingSession = newDesktopSession("Docker Hub staging", dockerhub.Staging())
|
||||
)
|
||||
|
||||
func NewDesktop(fallback AuthProvider) *Provider {
|
||||
return &Provider{
|
||||
fallback: fallback,
|
||||
hub: desktopHubSession,
|
||||
staging: desktopStagingSession,
|
||||
}
|
||||
auth, ok := s.auth()
|
||||
if !ok {
|
||||
return types.AuthConfig{}, false
|
||||
}
|
||||
auth.ServerAddress = serverAddress
|
||||
return auth, true
|
||||
}
|
||||
|
||||
func (p *provider) sessionFor(registryHostname string) (*session, string) {
|
||||
func (p *Provider) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
|
||||
if s, serverAddress := p.sessionFor(registryHostname); s != nil {
|
||||
if auth, ok := s.auth(); ok {
|
||||
auth.ServerAddress = serverAddress
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
return p.fallback.GetAuthConfig(registryHostname)
|
||||
}
|
||||
|
||||
func (p *Provider) sessionFor(registryHostname string) (*session, string) {
|
||||
switch credentials.ConvertToHostname(registryHostname) {
|
||||
case "docker.io", "index.docker.io", "registry-1.docker.io":
|
||||
return p.hub, indexServer
|
||||
case dhiRegistryHost:
|
||||
return p.hub, dhiRegistryHost
|
||||
case stagingRegistryHost:
|
||||
return p.staging, stagingRegistryHost
|
||||
case registry.IndexName, registry.IndexHostname, registry.DefaultRegistryHost:
|
||||
return p.hub, registry.IndexServer
|
||||
case registry.DHIRegistryHost:
|
||||
return p.hub, registry.DHIRegistryHost
|
||||
case registry.StagingRegistryHost:
|
||||
return p.staging, registry.StagingRegistryHost
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
@@ -94,29 +95,35 @@ func (p *provider) sessionFor(registryHostname string) (*session, string) {
|
||||
|
||||
type session struct {
|
||||
name string
|
||||
connect func() (hubSessions, error)
|
||||
connect func() (HubClient, error)
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
hub hubSessions
|
||||
hub HubClient
|
||||
retryAt time.Time
|
||||
cached *types.AuthConfig
|
||||
validUntil time.Time
|
||||
}
|
||||
|
||||
func newSession(name string, opts ...dockerhub.Option) *session {
|
||||
func newSession(name string, hub HubClient) *session {
|
||||
return &session{
|
||||
name: name,
|
||||
connect: func() (hubSessions, error) {
|
||||
return connect(api.DesktopSocketPath(), opts...)
|
||||
},
|
||||
name: name,
|
||||
hub: hub,
|
||||
timeout: lookupTimeout,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func connect(socketPath string, opts ...dockerhub.Option) (hubSessions, error) {
|
||||
func newDesktopSession(name string, opts ...dockerhub.Option) *session {
|
||||
s := newSession(name, nil)
|
||||
s.connect = func() (HubClient, error) {
|
||||
return connect(api.DesktopSocketPath(), opts...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func connect(socketPath string, opts ...dockerhub.Option) (HubClient, error) {
|
||||
if _, err := os.Lstat(socketPath); errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/internal/registry"
|
||||
seclient "github.com/docker/secrets-engine/client"
|
||||
"github.com/docker/secrets-engine/client/dockerhub"
|
||||
"gotest.tools/v3/assert"
|
||||
@@ -64,47 +65,47 @@ func hubSession(username string, expiresAt time.Time) dockerhub.UserSession {
|
||||
return s
|
||||
}
|
||||
|
||||
type recordingFallback struct {
|
||||
mu sync.Mutex
|
||||
hosts []string
|
||||
}
|
||||
|
||||
func (f *recordingFallback) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.hosts = append(f.hosts, registryHostname)
|
||||
return cliCredentials(registryHostname), nil
|
||||
}
|
||||
|
||||
func cliCredentials(registryHostname string) types.AuthConfig {
|
||||
return types.AuthConfig{Username: "cli-user", Password: "cli-password", ServerAddress: registryHostname}
|
||||
}
|
||||
|
||||
func desktopCredentials() types.AuthConfig {
|
||||
return types.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token", ServerAddress: indexServer}
|
||||
return types.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token", ServerAddress: registry.IndexServer}
|
||||
}
|
||||
|
||||
func stagingCredentials() types.AuthConfig {
|
||||
return types.AuthConfig{Username: "stageuser", Password: "staging-oauth-token", ServerAddress: stagingRegistryHost}
|
||||
return types.AuthConfig{Username: "stageuser", Password: "staging-oauth-token", ServerAddress: registry.StagingRegistryHost}
|
||||
}
|
||||
|
||||
type testProvider struct {
|
||||
*provider
|
||||
*Provider
|
||||
fallback *recordingFallback
|
||||
clock *fakeClock
|
||||
connects atomic.Int32
|
||||
staging *fakeHub
|
||||
}
|
||||
|
||||
func newTestProvider(hub hubSessions, connectErr error) *testProvider {
|
||||
func newTestProvider(hub HubClient) *testProvider {
|
||||
tp := &testProvider{
|
||||
clock: &fakeClock{t: testNow},
|
||||
staging: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile},
|
||||
fallback: &recordingFallback{},
|
||||
clock: &fakeClock{t: testNow},
|
||||
staging: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile},
|
||||
}
|
||||
tp.provider = &provider{
|
||||
hub: &session{
|
||||
name: "Docker Hub",
|
||||
connect: func() (hubSessions, error) {
|
||||
tp.connects.Add(1)
|
||||
if connectErr != nil {
|
||||
return nil, connectErr
|
||||
}
|
||||
return hub, nil
|
||||
},
|
||||
timeout: time.Second,
|
||||
now: tp.clock.now,
|
||||
},
|
||||
staging: &session{
|
||||
name: "Docker Hub staging",
|
||||
connect: func() (hubSessions, error) {
|
||||
return tp.staging, nil
|
||||
},
|
||||
timeout: time.Second,
|
||||
now: tp.clock.now,
|
||||
},
|
||||
tp.Provider = New(tp.fallback, hub, tp.staging)
|
||||
for _, s := range []*session{tp.Provider.hub, tp.Provider.staging} {
|
||||
s.timeout = time.Second
|
||||
s.now = tp.clock.now
|
||||
}
|
||||
return tp
|
||||
}
|
||||
@@ -123,75 +124,68 @@ func connectError(reason seclient.ConnectReason) error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableSkipsDockerDesktop(t *testing.T) {
|
||||
Disable()
|
||||
|
||||
_, ok := AuthConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
|
||||
shared.hub.mu.Lock()
|
||||
defer shared.hub.mu.Unlock()
|
||||
assert.Assert(t, shared.hub.retryAt.IsZero(), "Docker Desktop must not be contacted once disabled")
|
||||
assert.Assert(t, shared.hub.hub == nil)
|
||||
}
|
||||
|
||||
func TestSharedProviderHasOneSessionPerEnvironment(t *testing.T) {
|
||||
assert.Assert(t, shared.hub != nil)
|
||||
assert.Assert(t, shared.staging != nil)
|
||||
assert.Assert(t, shared.hub != shared.staging, "production and staging sessions must be separate")
|
||||
func TestNewDesktopSharesOneSessionPerEnvironment(t *testing.T) {
|
||||
a := NewDesktop(&recordingFallback{})
|
||||
b := NewDesktop(&recordingFallback{})
|
||||
assert.Assert(t, a.hub == b.hub, "providers must share the process-wide Docker Hub session")
|
||||
assert.Assert(t, a.staging == b.staging, "providers must share the process-wide Docker Hub staging session")
|
||||
assert.Assert(t, a.hub != a.staging, "production and staging sessions must be separate")
|
||||
}
|
||||
|
||||
func TestDockerHubUsesDesktopSession(t *testing.T) {
|
||||
for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io", indexServer} {
|
||||
for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io", registry.IndexServer} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, ok := p.authConfig(host)
|
||||
assert.Assert(t, ok)
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, got.IdentityToken, "", "an IdentityToken is sent as a refresh token")
|
||||
assert.Equal(t, len(p.fallback.hosts), 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherRegistriesDoNotContactDesktop(t *testing.T) {
|
||||
func TestOtherRegistriesUseFallback(t *testing.T) {
|
||||
hosts := []string{
|
||||
"ghcr.io", "localhost:5000", "registry.example.com", "",
|
||||
stagingRegistryHost + ".example.com", "example.com/" + stagingRegistryHost,
|
||||
dhiRegistryHost + ".example.com", "example.com/" + dhiRegistryHost, "registry." + dhiRegistryHost,
|
||||
registry.StagingRegistryHost + ".example.com", "example.com/" + registry.StagingRegistryHost,
|
||||
registry.DHIRegistryHost + ".example.com", "example.com/" + registry.DHIRegistryHost, "registry." + registry.DHIRegistryHost,
|
||||
"docker.io.example.com",
|
||||
}
|
||||
for _, host := range hosts {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
p.signInToStaging()
|
||||
|
||||
_, ok := p.authConfig(host)
|
||||
assert.Assert(t, !ok)
|
||||
assert.Equal(t, p.connects.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials(host))
|
||||
assert.DeepEqual(t, p.fallback.hosts, []string{host})
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
|
||||
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHIUsesDockerHubSession(t *testing.T) {
|
||||
for _, host := range []string{dhiRegistryHost, "https://" + dhiRegistryHost, "https://" + dhiRegistryHost + "/"} {
|
||||
for _, host := range []string{registry.DHIRegistryHost, "https://" + registry.DHIRegistryHost, "https://" + registry.DHIRegistryHost + "/"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
p.signInToStaging()
|
||||
|
||||
want := desktopCredentials()
|
||||
want.ServerAddress = dhiRegistryHost
|
||||
got, ok := p.authConfig(host)
|
||||
assert.Assert(t, ok)
|
||||
want.ServerAddress = registry.DHIRegistryHost
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, want)
|
||||
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "the staging session must not be read for DHI")
|
||||
|
||||
got, ok = p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
})
|
||||
@@ -199,14 +193,14 @@ func TestDHIUsesDockerHubSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStagingRegistryUsesStagingSession(t *testing.T) {
|
||||
for _, host := range []string{stagingRegistryHost, "https://" + stagingRegistryHost + "/"} {
|
||||
for _, host := range []string{registry.StagingRegistryHost, "https://" + registry.StagingRegistryHost + "/"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
p.signInToStaging()
|
||||
|
||||
got, ok := p.authConfig(host)
|
||||
assert.Assert(t, ok)
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, stagingCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "the production session must not be read for the staging registry")
|
||||
})
|
||||
@@ -215,25 +209,27 @@ func TestStagingRegistryUsesStagingSession(t *testing.T) {
|
||||
|
||||
func TestEnvironmentsDoNotMix(t *testing.T) {
|
||||
t.Run("staging session is not used for docker.io", func(t *testing.T) {
|
||||
p := newTestProvider(&fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}, nil)
|
||||
p := newTestProvider(&fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile})
|
||||
p.signInToStaging()
|
||||
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
|
||||
got, ok := p.authConfig(stagingRegistryHost)
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig(registry.StagingRegistryHost)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, stagingCredentials())
|
||||
})
|
||||
|
||||
t.Run("production session is not used for the staging registry", func(t *testing.T) {
|
||||
p := newTestProvider(&fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}, nil)
|
||||
p := newTestProvider(&fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))})
|
||||
|
||||
_, ok := p.authConfig(stagingRegistryHost)
|
||||
assert.Assert(t, !ok)
|
||||
got, err := p.GetAuthConfig(registry.StagingRegistryHost)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials(registry.StagingRegistryHost))
|
||||
|
||||
got, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
})
|
||||
}
|
||||
@@ -243,10 +239,10 @@ func TestUsernameFromProfile(t *testing.T) {
|
||||
session: hubSession("", testNow.Add(time.Hour)),
|
||||
profile: dockerhub.Profile{Username: "hubuser"},
|
||||
}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
}
|
||||
|
||||
@@ -267,45 +263,55 @@ func TestFallsBackWithoutUsableSession(t *testing.T) {
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := newTestProvider(tc.hub, nil)
|
||||
p := newTestProvider(tc.hub)
|
||||
|
||||
for range 2 {
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
}
|
||||
assert.Equal(t, tc.hub.sessionCalls.Load(), int32(1), "Desktop must not be asked again within retryInterval")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallsBackWhenClientCannotBeCreated(t *testing.T) {
|
||||
p := newTestProvider(nil, errors.New("no socket path"))
|
||||
func TestDesktopFallsBackWhenClientCannotBeCreated(t *testing.T) {
|
||||
var connects atomic.Int32
|
||||
s := newSession("Docker Hub", nil)
|
||||
s.connect = func() (HubClient, error) {
|
||||
connects.Add(1)
|
||||
return nil, errors.New("no socket path")
|
||||
}
|
||||
p := &Provider{fallback: &recordingFallback{}, hub: s}
|
||||
|
||||
for range 2 {
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
}
|
||||
assert.Equal(t, p.connects.Load(), int32(1))
|
||||
assert.Equal(t, connects.Load(), int32(1))
|
||||
}
|
||||
|
||||
func TestRetriesAfterInterval(t *testing.T) {
|
||||
hub := &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
|
||||
hub.sessionErr = nil
|
||||
hub.session = hubSession("hubuser", testNow.Add(time.Hour))
|
||||
|
||||
p.clock.advance(retryInterval - time.Second)
|
||||
_, ok = p.authConfig("docker.io")
|
||||
assert.Assert(t, !ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
|
||||
p.clock.advance(time.Second)
|
||||
got, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
}
|
||||
@@ -313,53 +319,58 @@ func TestRetriesAfterInterval(t *testing.T) {
|
||||
func TestReusesSessionUntilExpiry(t *testing.T) {
|
||||
expiresAt := testNow.Add(10 * time.Minute)
|
||||
hub := &fakeHub{session: hubSession("hubuser", expiresAt)}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
p.clock.advance(5 * time.Minute)
|
||||
_, ok = p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
|
||||
hub.session = hubSession("hubuser", expiresAt.Add(time.Hour))
|
||||
hub.session.AccessToken = "refreshed-token"
|
||||
p.clock.advance(5*time.Minute - sessionExpirySkew)
|
||||
|
||||
got, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got.Password, "refreshed-token")
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
}
|
||||
|
||||
func TestRefetchesSessionWithoutExpiry(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", time.Time{})}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
|
||||
_, ok := p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
p.clock.advance(sessionTTL - time.Second)
|
||||
_, ok = p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
|
||||
p.clock.advance(time.Second)
|
||||
_, ok = p.authConfig("docker.io")
|
||||
assert.Assert(t, ok)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
}
|
||||
|
||||
func TestConcurrentLookupsShareOneFetch(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
|
||||
p := newTestProvider(hub, nil)
|
||||
p := newTestProvider(hub)
|
||||
|
||||
const lookups = 20
|
||||
results := make([]types.AuthConfig, lookups)
|
||||
var wg sync.WaitGroup
|
||||
for i := range lookups {
|
||||
wg.Go(func() {
|
||||
auth, ok := p.authConfig("docker.io")
|
||||
assert.Check(t, ok)
|
||||
auth, err := p.GetAuthConfig("docker.io")
|
||||
assert.Check(t, err)
|
||||
results[i] = auth
|
||||
})
|
||||
}
|
||||
@@ -369,7 +380,6 @@ func TestConcurrentLookupsShareOneFetch(t *testing.T) {
|
||||
assert.DeepEqual(t, auth, desktopCredentials())
|
||||
}
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
assert.Equal(t, p.connects.Load(), int32(1))
|
||||
}
|
||||
|
||||
func TestConnectSkipsMissingSocket(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user