From 2fbbd740b5ccd7cf6037405a912718ffcd4aca94 Mon Sep 17 00:00:00 2001 From: Lohit Kolluri Date: Wed, 10 Jun 2026 16:26:32 +0530 Subject: [PATCH] e2e: add private registry pull/push regression test Add a privateregistry service (htpasswd auth, port 5001) to the e2e compose stack and a TestPullPushPrivateRepository test that verifies: - unauthenticated push/pull is rejected with an auth error - authenticated push/pull succeeds Fix private-registry flakiness by moving the registry debug listener off port 5001 (to avoid conflicting listeners) and fail fast during e2e setup if supporting services are not running. The volume path in compose-env.yaml is resolved relative to the compose file directory (e2e/), so use ./testdata/registry/auth, not ./e2e/testdata/registry/auth. Signed-off-by: Lohit Kolluri --- e2e/compose-env.yaml | 12 ++- e2e/image/private_test.go | 114 ++++++++++++++++++++++++++++ e2e/internal/fixtures/fixtures.go | 3 + e2e/testdata/registry/Dockerfile | 2 + e2e/testdata/registry/auth/htpasswd | 1 + scripts/test/e2e/run | 31 ++++++++ 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 e2e/image/private_test.go create mode 100644 e2e/testdata/registry/Dockerfile create mode 100644 e2e/testdata/registry/auth/htpasswd diff --git a/e2e/compose-env.yaml b/e2e/compose-env.yaml index 651d5d145aee..45935f969a4d 100644 --- a/e2e/compose-env.yaml +++ b/e2e/compose-env.yaml @@ -3,9 +3,19 @@ services: registry: image: 'registry:3' + privateregistry: + build: + context: ./testdata/registry + environment: + - REGISTRY_HTTP_ADDR=0.0.0.0:5001 + - REGISTRY_HTTP_DEBUG_ADDR=0.0.0.0:5002 + - REGISTRY_AUTH=htpasswd + - REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm + - REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd + engine: image: 'docker:${ENGINE_VERSION:-29}-dind' privileged: true - command: ['--insecure-registry=registry:5000', '--experimental'] + command: ['--insecure-registry=registry:5000', '--insecure-registry=privateregistry:5001', '--experimental'] environment: - DOCKER_TLS_CERTDIR= diff --git a/e2e/image/private_test.go b/e2e/image/private_test.go new file mode 100644 index 000000000000..8f922633a229 --- /dev/null +++ b/e2e/image/private_test.go @@ -0,0 +1,114 @@ +package image + +import ( + "strings" + "testing" + "time" + + "github.com/docker/cli/e2e/internal/fixtures" + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" +) + +const privateRegistryPrefix = "privateregistry:5001" + +// Regression test for https://github.com/docker/cli/issues/5963 +func TestPullPushPrivateRepository(t *testing.T) { + t.Parallel() + + dir := fixtures.SetupConfigFile(t) + t.Cleanup(dir.Remove) + emptyConfigDir := t.TempDir() + + sourceImage := fixtures.AlpineImage + privateImage := privateRegistryPrefix + "/private/alpine:test-private-pull-push" + + runWithPrivateRegistryRetry(t, + icmd.Command("docker", "pull", sourceImage), + ).Assert(t, icmd.Success) + t.Cleanup(func() { + icmd.RunCommand("docker", "image", "rm", "-f", privateImage).Assert(t, icmd.Success) + }) + + icmd.RunCommand("docker", "tag", sourceImage, privateImage).Assert(t, icmd.Success) + + pushNoAuth := runWithPrivateRegistryRetry(t, + icmd.Command("docker", "push", privateImage), + fixtures.WithConfig(emptyConfigDir), + ) + pushNoAuth.Assert(t, icmd.Expected{ExitCode: 1}) + assertAuthDenied(t, pushNoAuth) + + pushWithAuth := runWithPrivateRegistryRetry(t, + icmd.Command("docker", "push", privateImage), + fixtures.WithConfig(dir.Path()), + ) + pushWithAuth.Assert(t, icmd.Success) + // Docker omits the tag in the "push refers to repository" line; strip it before asserting. + privateRepo := privateImage[:strings.LastIndex(privateImage, ":")] + assert.Check(t, strings.Contains(pushWithAuth.Combined(), "The push refers to repository ["+privateRepo+"]"), pushWithAuth.Combined()) + + icmd.RunCommand("docker", "image", "rm", "-f", privateImage).Assert(t, icmd.Success) + + pullNoAuth := runWithPrivateRegistryRetry(t, + icmd.Command("docker", "pull", privateImage), + fixtures.WithConfig(emptyConfigDir), + ) + pullNoAuth.Assert(t, icmd.Expected{ExitCode: 1}) + assertAuthDenied(t, pullNoAuth) + + pullWithAuth := runWithPrivateRegistryRetry(t, + icmd.Command("docker", "pull", privateImage), + fixtures.WithConfig(dir.Path()), + ) + pullWithAuth.Assert(t, icmd.Success) + assert.Check(t, strings.Contains(pullWithAuth.Combined(), privateImage), pullWithAuth.Combined()) +} + +func assertAuthDenied(t *testing.T, result *icmd.Result) { + t.Helper() + output := result.Combined() + if isPrivateRegistryTransient(output) { + t.Fatalf("private registry unavailable while expecting auth failure: %s", output) + } + + assert.Assert(t, + strings.Contains(output, "requested access to the resource is denied") || + strings.Contains(output, "no basic auth credentials") || + strings.Contains(output, "unauthorized") || + strings.Contains(output, "authentication required"), + output, + ) +} + +func runWithPrivateRegistryRetry(t *testing.T, cmd icmd.Cmd, opts ...icmd.CmdOp) *icmd.Result { + t.Helper() + + deadline := time.Now().Add(90 * time.Second) + for { + result := icmd.RunCmd(cmd, opts...) + output := result.Combined() + if isPrivateRegistryTransient(output) { + if time.Now().Before(deadline) { + t.Logf("waiting for private registry availability: %s", output) + time.Sleep(500 * time.Millisecond) + continue + } + } + return result + } +} + +func isPrivateRegistryTransient(output string) bool { + return strings.Contains(output, "lookup privateregistry") || + strings.Contains(output, "lookup registry") || + strings.Contains(output, "no such host") || + strings.Contains(output, "server misbehaving") || + strings.Contains(output, "Temporary failure in name resolution") || + strings.Contains(output, "connection refused") || + strings.Contains(output, "i/o timeout") || + strings.Contains(output, "TLS handshake timeout") || + strings.Contains(output, "context deadline exceeded") || + strings.Contains(output, "connection reset by peer") || + strings.Contains(output, "unexpected EOF") +} diff --git a/e2e/internal/fixtures/fixtures.go b/e2e/internal/fixtures/fixtures.go index 256e14f17612..238942d1b7e0 100644 --- a/e2e/internal/fixtures/fixtures.go +++ b/e2e/internal/fixtures/fixtures.go @@ -23,6 +23,9 @@ func SetupConfigFile(t *testing.T) fs.Dir { "auths": { "registry:5000": { "auth": "ZWlhaXM6cGFzc3dvcmQK" + }, + "privateregistry:5001": { + "auth": "ZTJlOnBhc3N3b3Jk" } }}`), fs.WithDir("trust", fs.WithDir("private"))) return *dir diff --git a/e2e/testdata/registry/Dockerfile b/e2e/testdata/registry/Dockerfile new file mode 100644 index 000000000000..f79f1e236e8b --- /dev/null +++ b/e2e/testdata/registry/Dockerfile @@ -0,0 +1,2 @@ +FROM registry:3 +COPY auth /auth diff --git a/e2e/testdata/registry/auth/htpasswd b/e2e/testdata/registry/auth/htpasswd new file mode 100644 index 000000000000..708391c2c1cf --- /dev/null +++ b/e2e/testdata/registry/auth/htpasswd @@ -0,0 +1 @@ +e2e:$2y$05$DxRBsGSy61vZsBgNVxwUh.UtZmlg3wZHMxYcHYAlupY7r1xbIiuoq diff --git a/scripts/test/e2e/run b/scripts/test/e2e/run index a13359660fc6..862030b53fb2 100755 --- a/scripts/test/e2e/run +++ b/scripts/test/e2e/run @@ -28,6 +28,37 @@ setup() { fi COMPOSE_PROJECT_NAME=$project COMPOSE_FILE=$file docker compose up --build -d >&2 + # Ensure supporting services exist before running tests. If one fails to start, + # fail fast and surface logs instead of waiting on downstream DNS timeouts. + local deadline=$((SECONDS + 120)) + while [ $SECONDS -lt $deadline ]; do + local ok=1 + for svc in registry privateregistry engine; do + cid="$(COMPOSE_PROJECT_NAME=$project COMPOSE_FILE=$file docker compose ps -q "$svc" 2>/dev/null || true)" + if [ -z "$cid" ]; then + ok=0 + break + fi + if ! docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null | grep -q true; then + ok=0 + break + fi + done + if [ "$ok" -eq 1 ]; then + break + fi + sleep 1 + done + if [ $SECONDS -ge $deadline ]; then + echo "Timed out waiting for e2e services to start" >&2 + COMPOSE_PROJECT_NAME=$project COMPOSE_FILE=$file docker compose ps >&2 || true + for svc in registry privateregistry engine; do + echo "--- logs: $svc ---" >&2 + COMPOSE_PROJECT_NAME=$project COMPOSE_FILE=$file docker compose logs --no-color --tail=200 "$svc" >&2 || true + done + exit 1 + fi + local network="${project}_default" # TODO: only run if inside a container docker network connect "$network" "$(hostname)"