diff --git a/go.mod b/go.mod index 5051adf..e7e44ec 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/distribution/reference v0.6.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 + github.com/google/go-containerregistry v0.21.3 github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-runewidth v0.0.20 github.com/prometheus/client_model v0.6.2 @@ -25,7 +26,7 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/Microsoft/go-winio v0.4.21 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect @@ -37,6 +38,8 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect + github.com/docker/cli v29.3.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -46,6 +49,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kamstrup/intmap v0.5.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -58,7 +62,8 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -70,8 +75,8 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2ffb59a..959c08a 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= -github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw= @@ -51,8 +51,12 @@ github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mT github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -71,6 +75,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.3 h1:Xr+yt3VvwOOn/5nJzd7UoOhwPGiPkYW0zWDLLUXqAi4= +github.com/google/go-containerregistry v0.21.3/go.mod h1:D5ZrJF1e6dMzvInpBPuMCX0FxURz7GLq2rV3Us9aPkc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -89,6 +95,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -124,14 +132,13 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -169,15 +176,13 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= diff --git a/internal/docker/application.go b/internal/docker/application.go index 5c02ece..50162c4 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -274,7 +274,8 @@ func (a *Application) saveOperationResult(ctx context.Context, record func(*Stat } func (a *Application) pullImage(ctx context.Context, progress DeployProgressCallback) (bool, error) { - reader, err := a.namespace.client.ImagePull(ctx, a.Settings.Image, image.PullOptions{}) + opts := image.PullOptions{RegistryAuth: registryAuthFor(a.Settings.Image)} + reader, err := a.namespace.client.ImagePull(ctx, a.Settings.Image, opts) if err != nil { return false, fmt.Errorf("%w: %w", ErrPullFailed, err) } diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go new file mode 100644 index 0000000..11c418e --- /dev/null +++ b/internal/docker/registry_auth.go @@ -0,0 +1,32 @@ +package docker + +import ( + "encoding/base64" + "encoding/json" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +// registryAuthFor returns a base64-encoded JSON auth string for the registry +// that hosts the given image, suitable for use in image.PullOptions.RegistryAuth. +// Returns "" on any error or missing credentials, falling back to anonymous access. +func registryAuthFor(imageName string) string { + ref, err := name.ParseReference(imageName) + if err != nil { + return "" + } + authenticator, err := authn.DefaultKeychain.Resolve(ref.Context()) + if err != nil || authenticator == authn.Anonymous { + return "" + } + cfg, err := authenticator.Authorization() + if err != nil { + return "" + } + data, err := json.Marshal(cfg) + if err != nil { + return "" + } + return base64.URLEncoding.EncodeToString(data) +} diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go new file mode 100644 index 0000000..65ef65a --- /dev/null +++ b/internal/docker/registry_auth_test.go @@ -0,0 +1,186 @@ +package docker + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryAuthFor(t *testing.T) { + t.Run("invalid image string", func(t *testing.T) { + isolateDockerConfig(t) + assert.Equal(t, "", registryAuthFor(":::bad")) + }) + + t.Run("no docker config present", func(t *testing.T) { + isolateDockerConfig(t) + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) + + t.Run("reads config from DOCKER_CONFIG directory", func(t *testing.T) { + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("myuser:mypass")) + writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "myuser", ac.Username) + assert.Equal(t, "mypass", ac.Password) + }) + + t.Run("malformed config.json falls back to anonymous", func(t *testing.T) { + dir := isolateDockerConfig(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{not json}"), 0600)) + + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) + + t.Run("config has credHelpers for host", func(t *testing.T) { + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "myhelper", credHelperScript("helper-user", "helper-pass")) + writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "myhelper"}, "") + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "helper-user", ac.Username) + assert.Equal(t, "helper-pass", ac.Password) + }) + + t.Run("config has credsStore only", func(t *testing.T) { + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "mystore", credHelperScript("store-user", "store-pass")) + writeDockerConfig(t, dir, nil, nil, "mystore") + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "store-user", ac.Username) + assert.Equal(t, "store-pass", ac.Password) + }) + + t.Run("credHelpers wins over credsStore", func(t *testing.T) { + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "specific-helper", credHelperScript("helper-user", "helper-pass")) + // Real credential stores are indexed by hostname, so they don't return + // credentials when given a full repo path like "ghcr.io/basecamp/once". + // Simulate that by outputting the "credentials not found" message (the + // docker-credential-helpers protocol for "no credentials for this server"). + installFakeCredHelper(t, "global-store", `#!/bin/sh +input=$(cat) +if echo "$input" | grep -q '/'; then + echo "credentials not found in native keychain"; exit 1 +fi +echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' +`) + writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "specific-helper"}, "global-store") + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "helper-user", ac.Username) + assert.Equal(t, "helper-pass", ac.Password) + }) + + t.Run("config has inline auths entry", func(t *testing.T) { + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("inline-user:inline-pass")) + writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "inline-user", ac.Username) + assert.Equal(t, "inline-pass", ac.Password) + }) + + t.Run("credHelpers entry but helper fails - no fallback", func(t *testing.T) { + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "failing-helper", "#!/bin/sh\nexit 1\n") + writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "failing-helper"}, "") + + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) + + t.Run("no matching entry for host", func(t *testing.T) { + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) + writeDockerConfig(t, dir, map[string]string{"docker.io": encoded}, nil, "") + + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) +} + +// Helpers + +// isolateDockerConfig sets DOCKER_CONFIG to a fresh temp dir so tests don't +// touch the real Docker config. Returns the temp dir path. +func isolateDockerConfig(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + return dir +} + +// writeDockerConfig writes a Docker config.json into dir (which should be the +// value of $DOCKER_CONFIG). auths maps registry hostnames to base64 auth strings. +func writeDockerConfig(t *testing.T, dir string, auths map[string]string, credHelpers map[string]string, credsStore string) { + t.Helper() + type authEntry struct { + Auth string `json:"auth"` + } + cfg := struct { + Auths map[string]authEntry `json:"auths,omitempty"` + CredHelpers map[string]string `json:"credHelpers,omitempty"` + CredsStore string `json:"credsStore,omitempty"` + }{ + CredHelpers: credHelpers, + CredsStore: credsStore, + } + if len(auths) > 0 { + cfg.Auths = make(map[string]authEntry, len(auths)) + for k, v := range auths { + cfg.Auths[k] = authEntry{Auth: v} + } + } + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)) +} + +func installFakeCredHelper(t *testing.T, helperName, script string) { + t.Helper() + dir := t.TempDir() + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-"+helperName), []byte(script), 0755)) +} + +func credHelperScript(username, secret string) string { + payload, _ := json.Marshal(struct { + ServerURL string `json:"ServerURL"` + Username string `json:"Username"` + Secret string `json:"Secret"` + }{Username: username, Secret: secret}) + return fmt.Sprintf("#!/bin/sh\necho '%s'\n", payload) +} + +type authTokenPayload struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func decodeAuthToken(t *testing.T, token string) authTokenPayload { + t.Helper() + data, err := base64.URLEncoding.DecodeString(token) + require.NoError(t, err) + var ac authTokenPayload + require.NoError(t, json.Unmarshal(data, &ac)) + return ac +}