werkator build succeeded after 00:03
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>
252 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|