werkator build succeeded after 00:02
PUT /archive decided once, at the top level of the archive: a directory the image filled was refused, because binding it would hide what was inside. That was the right worry in the wrong place. Docker's clients put the whole path in the tar entries, so MariaDBContainer copying its configuration to /etc/mysql/conf.d arrives as a top-level "etc" — refused, and the container never started at all. The rule now descends instead of giving up: bind where the image has nothing, descend where it has a directory, bind the file where it has a file. The two trees meet at whatever depth they first differ, which is docker's merge; mariadb's config binds one file four levels down and /etc is left alone. The refusal that said werkdock "would have to hide them" is gone with it, because there is no longer a case where it would. Found by MariaDBContainer, which is now a test in werkprobe rather than something I ran once by hand. Squashed from branch archive-merge-deeper and pushed to main directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
8.2 KiB
Go
272 lines
8.2 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())
|
|
}
|
|
}
|
|
|
|
// A directory the image fills is not bound over — that would hide what
|
|
// is inside — but descended into, until the two trees first differ.
|
|
// This is the shape mariadb's own config copy has.
|
|
func TestArchiveDescendsIntoADirectoryTheImageAlreadyFills(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 != 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)
|
|
}
|
|
for _, m := range inst.Config.Spec.Mounts {
|
|
if m.Dest == "/opt/tools" {
|
|
t.Fatalf("the image's own /opt/tools was hidden by a bind: %v", m)
|
|
}
|
|
}
|
|
var found bool
|
|
for _, m := range inst.Config.Spec.Mounts {
|
|
if m.Dest == "/opt/tools/mine" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("the copied file was not bound; mounts are %v", inst.Config.Spec.Mounts)
|
|
}
|
|
// What the image shipped there is untouched and still visible.
|
|
if _, err := os.Stat(filepath.Join(rootfs, "opt", "tools", "shipped")); err != nil {
|
|
t.Fatalf("the image's own file went missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|