fix(dind): implement HEAD /containers/{id}/archive so docker cp works on stopped containers#77
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported failure
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 cpis a two-step wire dance:HEAD /containers/{id}/archive?path=…— the server must set anX-Docker-Container-Path-Statresponse 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.GET /containers/{id}/archive?path=…— only after HEAD parses, the CLI pulls the tar.pkg/dind/containers.gorouteContainer only matchedMethodPut(copy-to) andMethodGet(copy-from) for thearchiveaction. HEAD fell through tohandleNotImplemented, which returns a 501 JSON body with noX-Docker-Container-Path-Statheader. 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 usedocker exec tarworkarounds.Changes
pkg/dind/containers.go— addcase action == "archive" && r.Method == http.MethodHead:routing tos.handleContainerStatPath.pkg/dind/exec.go— newhandleContainerStatPathplus three small helpers shared with the existing GET path:rootfsSearchDirs— overlay upperdir/lowerdir lookup, test-stubbable vias.rootfsSearchDirsFn.resolveContainerPath— first-match in upperdir-first order.writeContainerPathStatHeader— emit the base64 JSON header.handleContainerCopyFromrefactored 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— optionalrootfsSearchDirsFnonServer. 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_NotFound— the 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 fakecontainerEntry+ tempdir rootfs with a known file, decodes theX-Docker-Container-Path-Statheader from the response, asserts the Docker-shaped struct'sName/Size/Modematch the planted file.TestArchiveGET_ReturnsStatHeader— regression guard for the shared header-emit on GET.Verified end-to-end:
TestArchiveHEAD_NotFoundon this branch (before the routing-table edit) returnedstatus = 501, want 404— the bug is real and the test catches it.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/pkcs11cgo preprocessing trap on Windows hosts forpkg/dind— seeAGENTS.md.)Workaround the reporter was considering
Streaming the tar via
docker run ... && cat /build/output.tarto stdout is no longer needed —docker cpfrom a stopped container now Just Works.