Files
werkdock/internal/api/archive_test.go
mhoennigandClaude Opus 5 eee4d5184c
werkator build succeeded after 00:03
A file into a container, without writing into the shared image
PUT /containers/{id}/archive was the last endpoint Testcontainers asks
for that werkdock answered with a 501. It is what withCopyFileToContainer
sends, and with it an image's own init mechanism works: a script copied
to /docker-entrypoint-initdb.d is run by the postgres entrypoint.

Docker unpacks the tar into the container's writable layer. Werkdock has
none, and its rootfs is the image's own directory in the store, shared by
every instance and bound read-only — unpacking there would change the
image for everyone. So the tar goes into the instance directory and is
bound into the sandbox, one of two ways, and the image decides which:
whole where the image has nothing at the destination, file by file where
it has something, so its own files stay visible beside the copied ones.
That is docker's merge semantics, reached by another road.

The real client corrected the design once: Testcontainers sends path=/
and carries the whole path in the tar entry names. An afternoon's
assumption that / should be refused would have refused every copy there
is. The measured call is now written down in docs/testcontainers-api.md,
where the guesses can be checked against it.

Refused rather than ignored: a copy into a running instance (mounts are
fixed at start), a directory onto one the image fills (the bind would
hide it), symlinks and device nodes, entries that climb out of the
destination, and archives above 64 entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:53:24 +02:00

252 lines
7.6 KiB
Go

package api
import (
"archive/tar"
"bytes"
"os"
"path/filepath"
"testing"
"werkdock/internal/instance"
)
// tarball builds an archive the way a docker client sends one.
type entry struct {
name string
dir bool
link string
body string
}
func tarball(t *testing.T, entries ...entry) string {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, e := range entries {
header := &tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.body)), Typeflag: tar.TypeReg}
switch {
case e.dir:
header.Typeflag, header.Mode, header.Size = tar.TypeDir, 0o755, 0
case e.link != "":
header.Typeflag, header.Linkname, header.Size = tar.TypeSymlink, e.link, 0
}
if err := tw.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(e.body)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return buf.String()
}
func createdInstance(t *testing.T, s *Server) string {
t.Helper()
w := do(t, s, "POST", "/v1.41/containers/create", `{"Image":"postgres:16-alpine"}`)
if w.Code != 201 {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
return decode(t, w)["Id"].(string)
}
// A copied file is unpacked into the instance and bound to its place,
// rather than written into the image every instance shares.
func TestArchiveCopiesAFileWithoutTouchingTheImage(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
body := tarball(t, entry{name: "init.sql", body: "create table t (i int);\n"})
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/docker-entrypoint-initdb.d", body)
if w.Code != 200 {
t.Fatalf("archive: %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
var mount string
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/docker-entrypoint-initdb.d" {
mount = m.Source
}
}
if mount == "" {
t.Fatalf("no mount for the copied file; mounts are %v", inst.Config.Spec.Mounts)
}
content, err := os.ReadFile(filepath.Join(mount, "init.sql"))
if err != nil {
t.Fatal(err)
}
if string(content) != "create table t (i int);\n" {
t.Fatalf("content is %q", content)
}
if !filepath.HasPrefix(mount, inst.Dir) {
t.Fatalf("the copy landed outside the instance: %s", mount)
}
// The image is shared, so the content must not be in it. The
// destination is empty in this image, so one directory mount
// carries the copy and only that mountpoint appears there.
rootfs, err := s.Store.RootFS("postgres:16-alpine")
if err != nil {
t.Fatal(err)
}
if mount != filepath.Join(inst.Dir, "archive", "docker-entrypoint-initdb.d") {
t.Fatalf("expected one directory mount, got %s", mount)
}
shared := filepath.Join(rootfs, "docker-entrypoint-initdb.d")
entries, err := os.ReadDir(shared)
if err != nil {
t.Fatalf("the mountpoint should exist in the image: %v", err)
}
if len(entries) != 0 {
t.Fatalf("the copy leaked into the shared image: %v", entries)
}
}
// The mount is fixed when the sandbox starts, so a running instance is
// told why rather than left to wonder.
func TestArchiveIntoARunningInstanceIsRefusedWithTheReason(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
inst.State.Status = instance.StatusRunning
inst.State.PID = os.Getpid() // load() reconciles a dead supervisor away
if err := inst.SaveState(); err != nil {
t.Fatal(err)
}
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/tmp", tarball(t, entry{name: "x", body: "y"}))
if w.Code != 501 {
t.Fatalf("want 501, got %d %s", w.Code, w.Body.String())
}
if msg := w.Body.String(); !bytes.Contains([]byte(msg), []byte("fixed when it starts")) {
t.Fatalf("the refusal does not say why: %s", msg)
}
}
// Docker's clients put the whole path in the tar and send / as the
// destination — Testcontainers does. That must land one level down and
// never bind over the shared rootfs itself.
func TestArchiveToTheRootLandsOneLevelDown(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/",
tarball(t,
entry{name: "docker-entrypoint-initdb.d", dir: true},
entry{name: "docker-entrypoint-initdb.d/init.sql", body: "select 1;\n"}))
if w.Code != 200 {
t.Fatalf("want 200, got %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
var dest string
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/" {
t.Fatalf("the rootfs itself was bound over: %v", m)
}
if m.Dest == "/docker-entrypoint-initdb.d" {
dest = m.Source
}
}
if dest == "" {
t.Fatalf("nothing was bound at the copied directory; mounts are %v", inst.Config.Spec.Mounts)
}
if _, err := os.Stat(filepath.Join(dest, "init.sql")); err != nil {
t.Fatal(err)
}
}
// A symlink in the archive would have to mean something inside the
// sandbox that werkdock cannot promise.
func TestArchiveRefusesWhatItCannotHonour(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/tmp",
tarball(t, entry{name: "link", link: "/etc/passwd"}))
if w.Code != 400 {
t.Fatalf("want 400, got %d %s", w.Code, w.Body.String())
}
}
// An archive entry that climbs out of its destination is refused.
func TestArchiveRefusesAnEntryThatEscapes(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/tmp",
tarball(t, entry{name: "../../escaped", body: "x"}))
if w.Code != 400 {
t.Fatalf("want 400, got %d %s", w.Code, w.Body.String())
}
}
// Binding a directory over one the image fills would hide its contents,
// which werkdock will not do silently.
func TestArchiveRefusesADirectoryTheImageAlreadyFills(t *testing.T) {
s := testServer(t)
rootfs, err := s.Store.RootFS("postgres:16-alpine")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(rootfs, "opt", "tools"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rootfs, "opt", "tools", "shipped"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/opt",
tarball(t, entry{name: "tools", dir: true}, entry{name: "tools/mine", body: "y"}))
if w.Code != 501 {
t.Fatalf("want 501, got %d %s", w.Code, w.Body.String())
}
if msg := w.Body.String(); !bytes.Contains([]byte(msg), []byte("already has")) {
t.Fatalf("the refusal does not say why: %s", msg)
}
}
// Where the image fills the destination, the copy is bound file by
// file, so what the image ships stays visible beside it.
func TestArchiveIntoAnImageFilledDirectoryBindsFileByFile(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/etc",
tarball(t, entry{name: "mine.conf", body: "key = value\n"}))
if w.Code != 200 {
t.Fatalf("archive: %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/etc" {
t.Fatalf("/etc was bound whole, which hides the image's own files: %v", m)
}
}
var found bool
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/etc/mine.conf" {
found = true
}
}
if !found {
t.Fatalf("no mount for the copied file; mounts are %v", inst.Config.Spec.Mounts)
}
// The image's own /etc/passwd is still there, unbound and unhidden.
rootfs, _ := s.Store.RootFS("postgres:16-alpine")
if _, err := os.Stat(filepath.Join(rootfs, "etc", "passwd")); err != nil {
t.Fatalf("the image's own file went missing: %v", err)
}
}