From 236b3d44c6831a9e929f91c3ef7f1f2ef1e6b8cd Mon Sep 17 00:00:00 2001 From: Lohit Kolluri Date: Wed, 27 May 2026 21:27:47 +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. Regression test for docker#5963. Closes docker#5965. Signed-off-by: aryansharma9917 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)"