Skip to content

fix(dind): implement HEAD /containers/{id}/archive so docker cp works on stopped containers#77

Merged
luthermonson merged 1 commit into
mainfrom
fix/dind-archive-head
May 25, 2026
Merged

fix(dind): implement HEAD /containers/{id}/archive so docker cp works on stopped containers#77
luthermonson merged 1 commit into
mainfrom
fix/dind-archive-head

Conversation

@luthermonson
Copy link
Copy Markdown
Contributor

Reported failure

$ docker run --name spc-build alpine:3.20 sh -c '... 15-minute PHP build ...'
... BUILD SUCCESSFUL ...
$ docker cp spc-build:/build/output.tar output.tar
unable to get resource stat from response: unable to decode container path stat header: EOF

Happens on x86_64 and aarch64 Linux runners, for single files and directory copies, on stopped (but not removed) containers. Works fine with the real Docker daemon.

Root cause — routing gap, not a stopped-container bug

Docker CLI's docker cp is a two-step wire dance:

  1. HEAD /containers/{id}/archive?path=… — the server must set an X-Docker-Container-Path-Stat response header (base64-encoded JSON {name, size, mode, mtime, linkTarget}). The CLI parses it to decide whether the source is a file or a directory and pick the local destination kind.
  2. GET /containers/{id}/archive?path=… — only after HEAD parses, the CLI pulls the tar.

pkg/dind/containers.go routeContainer only matched MethodPut (copy-to) and MethodGet (copy-from) for the archive action. HEAD fell through to handleNotImplemented, which returns a 501 JSON body with no X-Docker-Container-Path-Stat header. The CLI's header decoder reads the empty header, base64-decodes to empty bytes, json-decodes from an empty buffer, and produces verbatim the EOF you saw. The GET handler was fine — but the CLI never got to call it.

The stopped-container framing in the report is incidental: the bug hits running containers too. Most callers don't notice because they either use --rm (so the container's gone by the time anyone cps) or use docker exec tar workarounds.

Changes

  • pkg/dind/containers.go — add case action == "archive" && r.Method == http.MethodHead: routing to s.handleContainerStatPath.
  • pkg/dind/exec.go — new handleContainerStatPath plus three small helpers shared with the existing GET path:
    • rootfsSearchDirs — overlay upperdir/lowerdir lookup, test-stubbable via s.rootfsSearchDirsFn.
    • resolveContainerPath — first-match in upperdir-first order.
    • writeContainerPathStatHeader — emit the base64 JSON header.
      handleContainerCopyFrom refactored to share the helpers and now also sets the stat header on the GET response (some Docker clients skip the HEAD pre-flight and rely on the GET header).
  • pkg/dind/dind.go — optional rootfsSearchDirsFn on Server. Nil in production → real containerd snapshotter; tests inject to avoid standing up containerd.

Test plan

pkg/dind/archive_head_test.go — three new tests:

  • TestArchiveHEAD_NotFoundthe failing-test-first proof. HEAD on a missing container must return 404 (matching PUT/GET). Run against main without the routing change: status = 501, want 404. After the fix: passes.
  • TestArchiveHEAD_ReturnsStatHeader — plants a fake containerEntry + tempdir rootfs with a known file, decodes the X-Docker-Container-Path-Stat header from the response, asserts the Docker-shaped struct's Name/Size/Mode match the planted file.
  • TestArchiveGET_ReturnsStatHeader — regression guard for the shared header-emit on GET.

Verified end-to-end:

  • Pre-fix run of TestArchiveHEAD_NotFound on this branch (before the routing-table edit) returned status = 501, want 404 — the bug is real and the test catches it.
  • Post-fix full go test ./pkg/dind/... is green.
  • GOOS=linux ./bin/golangci-lint run ./... clean.

(Tests must run with Go 1.26.1 in Linux/WSL; the project hits the documented miekg/pkcs11 cgo preprocessing trap on Windows hosts for pkg/dind — see AGENTS.md.)

Workaround the reporter was considering

Streaming the tar via docker run ... && cat /build/output.tar to stdout is no longer needed — docker cp from a stopped container now Just Works.

Reported failure mode on stopped containers in nested containerd:

  $ docker run --name spc-build alpine:3.20 sh -c '...long build...'
  ...BUILD SUCCESSFUL...
  $ docker cp spc-build:/build/output.tar output.tar
  unable to get resource stat from response:
  unable to decode container path stat header: EOF

Root cause was a routing gap, not a stopped-container bug. Docker CLI's
`docker cp` issues a HEAD /containers/{id}/archive?path=… first to read
the X-Docker-Container-Path-Stat header (base64 JSON of name/size/
mode/mtime/linkTarget) before falling through to GET for the tar bytes.
routeContainer only matched MethodPut/MethodGet for the archive action;
HEAD fell through to handleNotImplemented (501). The CLI's header
decoder then read the missing header and produced the EOF above.

Changes:

* containers.go: add `case action == "archive" && r.Method == HEAD`
  routing to s.handleContainerStatPath.

* exec.go: new handleContainerStatPath plus three small helpers
  shared with the existing GET path —
    rootfsSearchDirs              overlay upperdir/lowerdir lookup,
                                  test-stubbable via rootfsSearchDirsFn
    resolveContainerPath          first-match in upperdir-first order
    writeContainerPathStatHeader  emit the base64 JSON header
  handleContainerCopyFrom is refactored to share the same helpers and
  now also sets the stat header on the GET response (some clients skip
  the HEAD pre-flight and rely on the GET header).

* dind.go: optional rootfsSearchDirsFn on Server. Nil in production —
  falls through to the real containerd snapshotter path; tests inject
  to avoid standing up containerd.

Tests in pkg/dind/archive_head_test.go:

  TestArchiveHEAD_NotFound        proves the routing gap; before the
                                  fix this returned 501 (run on main
                                  without the routing change to confirm)
  TestArchiveHEAD_ReturnsStatHeader   plants a fake container + rootfs,
                                      decodes the header, asserts the
                                      Docker-shaped struct populates
                                      Name/Size/Mode correctly
  TestArchiveGET_ReturnsStatHeader    regression guard for the shared
                                      header-emit on GET responses

Verified locally against the project Go 1.26.1 in WSL (the cgo
preprocessing trap on Windows hosts noted in AGENTS.md still applies);
GOOS=linux golangci-lint clean.
@luthermonson luthermonson merged commit a7443fe into main May 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant