Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions pkg/dind/archive_head_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package dind

import (
"context"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)

// TestArchiveHEAD_NotFound is the lightweight routing test. A HEAD against
// /containers/{id}/archive for a container the daemon doesn't know about
// must respond 404, matching the existing PUT and GET handlers in
// TestCopyToNotFound / TestCopyFromNotFound. Before this fix the route
// fell through to handleNotImplemented (501) because no MethodHead case
// existed in routeContainer — that's exactly the routing gap that makes
// the Docker CLI's `docker cp` produce "unable to decode container path
// stat header: EOF" on stopped containers.
func TestArchiveHEAD_NotFound(t *testing.T) {
s := newTestServer(t)
client := dialServer(s)

req, err := http.NewRequest("HEAD", "http://docker/containers/nonexistent/archive?path=/tmp", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("HEAD /containers/nonexistent/archive: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
t.Logf("closing response body: %v", err)
}
}()

if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404 (matching PUT and GET archive handlers)", resp.StatusCode)
}
}

// TestArchiveHEAD_ReturnsStatHeader plants a fake containerEntry pointing at
// a tempdir with a known file, then issues a HEAD and asserts the
// X-Docker-Container-Path-Stat header decodes into a Docker-shaped struct
// with the expected name/size/mode. This is the precise contract the Docker
// CLI's StatPath uses — anything less than this and `docker cp` returns
// "unable to decode container path stat header: EOF" even on a 200 response.
func TestArchiveHEAD_ReturnsStatHeader(t *testing.T) {
rootfs := t.TempDir()
const wantName = "output.tar"
const wantBody = "hello-from-stopped-container\n"
if err := os.WriteFile(filepath.Join(rootfs, wantName), []byte(wantBody), 0o644); err != nil {
t.Fatalf("seeding rootfs file: %v", err)
}

s := &Server{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
containers: map[string]*containerEntry{},
}
const cid = "abc123"
s.containers[cid] = &containerEntry{ID: cid, Status: "exited"}
// Test override: hand the HEAD handler our planted rootfs without
// going through containerd's snapshotter.
s.rootfsSearchDirsFn = func(_ context.Context, _ string) ([]string, error) {
return []string{rootfs}, nil
}

req, err := http.NewRequest("HEAD", "http://docker/containers/"+cid+"/archive?path=/"+wantName, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
rec := httptest.NewRecorder()
s.routeContainer(rec, req, "/containers/"+cid+"/archive")

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}

hdr := rec.Header().Get("X-Docker-Container-Path-Stat")
if hdr == "" {
t.Fatal("X-Docker-Container-Path-Stat header missing — CLI will fail with 'unable to decode container path stat header: EOF'")
}
raw, err := base64.StdEncoding.DecodeString(hdr)
if err != nil {
t.Fatalf("header not valid base64: %v", err)
}
// Docker's containerPathStat shape (engine-api types/container/file.go).
var got struct {
Name string `json:"name"`
Size int64 `json:"size"`
Mode os.FileMode `json:"mode"`
Mtime time.Time `json:"mtime"`
LinkTarget string `json:"linkTarget"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("header JSON: %v", err)
}
if got.Name != wantName {
t.Errorf("Name = %q, want %q", got.Name, wantName)
}
if got.Size != int64(len(wantBody)) {
t.Errorf("Size = %d, want %d", got.Size, len(wantBody))
}
if got.Mode.IsDir() {
t.Errorf("Mode = %v, want regular file", got.Mode)
}
}

// TestArchiveGET_ReturnsStatHeader asserts the same X-Docker-Container-Path-Stat
// header is set on the GET response too. Some Docker clients skip the HEAD
// pre-flight and rely on the header on the GET. Regression guard for the
// shared header-emit code path.
func TestArchiveGET_ReturnsStatHeader(t *testing.T) {
rootfs := t.TempDir()
const wantName = "data.bin"
if err := os.WriteFile(filepath.Join(rootfs, wantName), []byte("xyz"), 0o644); err != nil {
t.Fatalf("seeding rootfs file: %v", err)
}
s := &Server{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
containers: map[string]*containerEntry{},
}
const cid = "def456"
s.containers[cid] = &containerEntry{ID: cid, Status: "exited"}
s.rootfsSearchDirsFn = func(_ context.Context, _ string) ([]string, error) {
return []string{rootfs}, nil
}

req, err := http.NewRequest("GET", "http://docker/containers/"+cid+"/archive?path=/"+wantName, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
rec := httptest.NewRecorder()
s.routeContainer(rec, req, "/containers/"+cid+"/archive")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Docker-Container-Path-Stat") == "" {
t.Error("GET response missing X-Docker-Container-Path-Stat header")
}
}
25 changes: 13 additions & 12 deletions pkg/dind/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,17 @@ type createRequest struct {
}

type hostConfig struct {
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
Privileged bool `json:"Privileged"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
Tmpfs map[string]string `json:"Tmpfs"`
PortBindings map[string][]portBinding `json:"PortBindings"`
RestartPolicy *restartPolicy `json:"RestartPolicy"`
Init *bool `json:"Init"`
CgroupnsMode string `json:"CgroupnsMode"`
ExtraHosts []string `json:"ExtraHosts"`
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
Privileged bool `json:"Privileged"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
Tmpfs map[string]string `json:"Tmpfs"`
PortBindings map[string][]portBinding `json:"PortBindings"`
RestartPolicy *restartPolicy `json:"RestartPolicy"`
Init *bool `json:"Init"`
CgroupnsMode string `json:"CgroupnsMode"`
ExtraHosts []string `json:"ExtraHosts"`
}

type portBinding struct {
Expand Down Expand Up @@ -181,6 +181,8 @@ func (s *Server) routeContainer(w http.ResponseWriter, r *http.Request, path str
s.handleContainerCopyTo(w, r, id)
case action == "archive" && r.Method == http.MethodGet:
s.handleContainerCopyFrom(w, r, id)
case action == "archive" && r.Method == http.MethodHead:
s.handleContainerStatPath(w, r, id)
default:
s.handleNotImplemented(w, r)
}
Expand Down Expand Up @@ -1563,4 +1565,3 @@ func writeContainerHosts(entry *containerEntry) error {

return os.WriteFile(entry.HostsPath, []byte(b.String()), 0o644)
}

9 changes: 8 additions & 1 deletion pkg/dind/dind.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,18 @@ type Server struct {
log *slog.Logger

mu sync.Mutex
images map[string]*imageEntry // in-memory image store scoped to this job
images map[string]*imageEntry // in-memory image store scoped to this job
containers map[string]*containerEntry // containers created through this socket
execs map[string]*execEntry // exec processes inside containers
networks map[string]*networkEntry // Docker networks (logical, in-memory)
auth authCache // per-job docker login cache (registry host → creds)

// rootfsSearchDirsFn resolves the host-filesystem directories that
// together form the merged rootfs view for a container snapshot.
// Nil in production — handleContainerStatPath / handleContainerCopyFrom
// fall through to the real containerd snapshotter path. Tests stub
// this to avoid standing up containerd.
rootfsSearchDirsFn func(ctx context.Context, snapshotID string) ([]string, error)
}

type imageEntry struct {
Expand Down
Loading