werkator build succeeded after 00:04
Found on mih09 with Werkator 1.37.0 (repository werkrevisor, PR#5): a CI build and a pull-request review started together and both imported the same new rootfs. ## Imports of one image wait for each other `Store.Import` stages in `<image>.tmp` and removes that directory first, so the next import cleans up after an interrupted one. A second import at the same time removed the first one's files: one failed with `latest.tmp/image.json: no such file or directory`, the other's sandbox could not execute `/bin/sh`. An import now holds an exclusive `flock` on `$WERKDOCK_HOME/locks/import-<hash>.lock`, across goroutines and processes; the kernel drops it when the holder dies. The import that waited refuses with `already exists`, as an import after it would. `TestConcurrentImportsOfOneImageLeaveOneCompleteImage`: four imports of a 3000-file archive at once leave one success, three refusals and one complete image. It failed 3 of 3 runs without the lock. ## `werkdock run` answers 127 or 126 `engine.CheckCommand` looks the command up like execvp inside the rootfs (symlinks resolved in the rootfs, PATH in order, a non-executable candidate skipped for a later one). `run` refuses a command that cannot start with docker's 127 (not found) or 126 (not executable) before anything is created; until now bwrap's 1 came back. Paths under a mount, the implicit tmpfs areas included, are not checked. `create`/`start` and the Docker API are unchanged. The resolver moved from `store` to its own package `rootpath`, shared with `engine`. Smoke-tested with the built binary: `run smoke missing` exits 127, a file without execute bit 126, a second import 125 with `already exists`. Version 0.11.0 with its CHANGELOG section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #13
331 lines
12 KiB
Go
331 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// tarEntry is one member of a test layer.
|
|
type tarEntry struct {
|
|
name, content, link string
|
|
typ byte
|
|
mode int64
|
|
}
|
|
|
|
func tarBytes(t *testing.T, entries []tarEntry) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
for _, e := range entries {
|
|
hdr := &tar.Header{Name: e.name, Mode: e.mode, Typeflag: e.typ, Linkname: e.link}
|
|
if hdr.Mode == 0 {
|
|
hdr.Mode = 0o644
|
|
if e.typ == tar.TypeDir {
|
|
hdr.Mode = 0o755
|
|
}
|
|
}
|
|
if e.typ == 0 {
|
|
hdr.Typeflag = tar.TypeReg
|
|
}
|
|
hdr.Size = int64(len(e.content))
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tw.Write([]byte(e.content)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func gzipBytes(t *testing.T, raw []byte) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
if _, err := gz.Write(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// writeDockerSave builds a legacy-layout `docker save` archive at path:
|
|
// manifest.json, <id>.json config, <n>/layer.tar per layer.
|
|
func writeDockerSave(t *testing.T, path string, repoTags []string, config string, layers [][]byte) {
|
|
t.Helper()
|
|
var files []tarEntry
|
|
var layerPaths []string
|
|
for i, l := range layers {
|
|
name := strings.Repeat("a", 3) + string(rune('0'+i)) + "/layer.tar"
|
|
files = append(files, tarEntry{name: name, content: string(l)})
|
|
layerPaths = append(layerPaths, name)
|
|
}
|
|
files = append(files, tarEntry{name: "cfg.json", content: config})
|
|
manifest, _ := json.Marshal([]map[string]interface{}{{"Config": "cfg.json", "RepoTags": repoTags, "Layers": layerPaths}})
|
|
files = append(files, tarEntry{name: "manifest.json", content: string(manifest)})
|
|
if err := os.WriteFile(path, tarBytes(t, files), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func skipWithoutTar(t *testing.T) {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
}
|
|
|
|
const testConfig = `{"architecture":"amd64","os":"linux","created":"2026-09-05T12:00:00Z",
|
|
"config":{"Env":["PATH=/usr/local/bin:/bin","JAVA_HOME=/opt/jdk"],"Entrypoint":["/bin/sh","-c"],"Cmd":["java -version"],"WorkingDir":"/work","Labels":{"k":"v"}}}`
|
|
|
|
func TestLoadFlattensLayersWithWhiteoutsAndKeepsTheConfig(t *testing.T) {
|
|
skipWithoutTar(t)
|
|
lower := tarBytes(t, []tarEntry{
|
|
{name: "bin/", typ: tar.TypeDir},
|
|
{name: "bin/sh", content: "#!lower sh", mode: 0o755},
|
|
{name: "etc/", typ: tar.TypeDir},
|
|
{name: "etc/old.conf", content: "gone in upper"},
|
|
{name: "etc/keep.conf", content: "kept"},
|
|
{name: "opt/", typ: tar.TypeDir},
|
|
{name: "opt/cache/", typ: tar.TypeDir},
|
|
{name: "opt/cache/a", content: "lower a"},
|
|
{name: "opt/cache/b", content: "lower b"},
|
|
{name: "usr/", typ: tar.TypeDir},
|
|
{name: "usr/bin/", typ: tar.TypeDir},
|
|
{name: "usr/bin/real", content: "real"},
|
|
{name: "sbin", typ: tar.TypeSymlink, link: "usr/bin"},
|
|
{name: "var/", typ: tar.TypeDir},
|
|
{name: "var/wasdir/", typ: tar.TypeDir},
|
|
{name: "var/wasdir/child", content: "child"},
|
|
})
|
|
upper := gzipBytes(t, tarBytes(t, []tarEntry{
|
|
{name: "bin/sh", content: "#!upper sh", mode: 0o755},
|
|
{name: "etc/.wh.old.conf"},
|
|
{name: "opt/cache/.wh..wh..opq"},
|
|
{name: "opt/cache/c", content: "upper c"},
|
|
{name: "sbin/viasymlink", content: "through the lower layer's symlink"},
|
|
{name: "var/wasdir", content: "now a file"},
|
|
{name: "usr/bin/hard", typ: tar.TypeLink, link: "usr/bin/real"},
|
|
}))
|
|
archive := filepath.Join(t.TempDir(), "saved.tar")
|
|
writeDockerSave(t, archive, []string{"example/app:1.0"}, testConfig, [][]byte{lower, upper})
|
|
st := Store{Root: t.TempDir()}
|
|
|
|
loaded, err := st.Load(archive, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(loaded, ",") != "example/app:1.0" {
|
|
t.Errorf("loaded %v", loaded)
|
|
}
|
|
rootfs, err := st.RootFS("example/app:1.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
read := func(rel string) string {
|
|
raw, err := os.ReadFile(filepath.Join(rootfs, rel))
|
|
if err != nil {
|
|
return "ERR:" + err.Error()
|
|
}
|
|
return string(raw)
|
|
}
|
|
checks := map[string]string{
|
|
"bin/sh": "#!upper sh",
|
|
"etc/keep.conf": "kept",
|
|
"opt/cache/c": "upper c",
|
|
"usr/bin/viasymlink": "through the lower layer's symlink",
|
|
"var/wasdir": "now a file",
|
|
"usr/bin/hard": "real",
|
|
}
|
|
for rel, want := range checks {
|
|
if got := read(rel); got != want {
|
|
t.Errorf("%s: got %q, want %q", rel, got, want)
|
|
}
|
|
}
|
|
for _, gone := range []string{"etc/old.conf", "opt/cache/a", "opt/cache/b", "etc/.wh.old.conf", "opt/cache/.wh..wh..opq"} {
|
|
if _, err := os.Lstat(filepath.Join(rootfs, gone)); !os.IsNotExist(err) {
|
|
t.Errorf("%s should not exist: %v", gone, err)
|
|
}
|
|
}
|
|
if fi, err := os.Stat(filepath.Join(rootfs, "bin/sh")); err != nil || fi.Mode().Perm() != 0o755 {
|
|
t.Errorf("bin/sh mode: %v %v", fi, err)
|
|
}
|
|
if link, err := os.Readlink(filepath.Join(rootfs, "sbin")); err != nil || link != "usr/bin" {
|
|
t.Errorf("sbin symlink: %q %v", link, err)
|
|
}
|
|
meta, err := st.Meta("example/app:1.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meta.Architecture != "amd64" || meta.OS != "linux" || meta.CreatedAt.Year() != 2026 ||
|
|
strings.Join(meta.Config.Entrypoint, " ") != "/bin/sh -c" || strings.Join(meta.Config.Cmd, " ") != "java -version" ||
|
|
meta.Config.WorkingDir != "/work" || len(meta.Config.Env) != 2 || meta.Config.Labels["k"] != "v" {
|
|
t.Errorf("metadata: %+v", meta)
|
|
}
|
|
if !strings.HasSuffix(meta.Source, "saved.tar") {
|
|
t.Errorf("source: %q", meta.Source)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "example", "app", "1.0", "image.json")); err != nil {
|
|
t.Errorf("image.json missing: %v", err)
|
|
}
|
|
leftovers, _ := filepath.Glob(filepath.Join(st.Root, "images", ".load-*"))
|
|
if len(leftovers) != 0 {
|
|
t.Errorf("staging left behind: %v", leftovers)
|
|
}
|
|
}
|
|
|
|
func TestLoadContainsEscapingPathsInsideTheRootfs(t *testing.T) {
|
|
skipWithoutTar(t)
|
|
// Every escape attempt resolves the way it would inside the sandbox:
|
|
// ".." above the root is the root, so the files land in the rootfs
|
|
// and nothing is written elsewhere in the store.
|
|
layer := tarBytes(t, []tarEntry{
|
|
{name: "../../dotdot", content: "x"},
|
|
{name: "escape", typ: tar.TypeSymlink, link: "../../.."},
|
|
{name: "escape/etc/passwd", content: "via relative symlink"},
|
|
{name: "abs", typ: tar.TypeSymlink, link: "/../.."},
|
|
{name: "abs/via-abs", content: "via absolute symlink"},
|
|
{name: "real", content: "hardlink target"},
|
|
{name: "h", typ: tar.TypeLink, link: "escape/real"},
|
|
})
|
|
st := Store{Root: t.TempDir()}
|
|
archive := filepath.Join(t.TempDir(), "evil.tar")
|
|
writeDockerSave(t, archive, []string{"evil"}, testConfig, [][]byte{layer})
|
|
if _, err := st.Load(archive, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rootfs, _ := st.RootFS("evil")
|
|
for rel, want := range map[string]string{"dotdot": "x", "etc/passwd": "via relative symlink", "via-abs": "via absolute symlink", "h": "hardlink target"} {
|
|
if raw, err := os.ReadFile(filepath.Join(rootfs, rel)); err != nil || string(raw) != want {
|
|
t.Errorf("%s: %q %v", rel, raw, err)
|
|
}
|
|
}
|
|
entries, _ := os.ReadDir(filepath.Join(st.Root, "images"))
|
|
for _, e := range entries {
|
|
if e.Name() != "evil" {
|
|
t.Errorf("unexpected entry beside the image: %s", e.Name())
|
|
}
|
|
}
|
|
if entries, _ := os.ReadDir(st.Root); len(entries) != 1 {
|
|
t.Errorf("store root polluted: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestLoadNamesFromTheArchiveOrTheCaller(t *testing.T) {
|
|
skipWithoutTar(t)
|
|
layer := tarBytes(t, []tarEntry{{name: "hello", content: "hi"}})
|
|
st := Store{Root: t.TempDir()}
|
|
|
|
// Two RepoTags: the second is a hardlinked copy of the first.
|
|
tagged := filepath.Join(t.TempDir(), "tagged.tar")
|
|
writeDockerSave(t, tagged, []string{"app:1.0", "app:latest"}, testConfig, [][]byte{layer})
|
|
loaded, err := st.Load(tagged, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(loaded, ",") != "app:1.0,app" {
|
|
t.Errorf("loaded %v", loaded)
|
|
}
|
|
for _, ref := range []string{"app:1.0", "app"} {
|
|
if _, err := st.RootFS(ref); err != nil {
|
|
t.Errorf("%s: %v", ref, err)
|
|
}
|
|
}
|
|
|
|
// No RepoTags: the caller must name it.
|
|
unnamed := filepath.Join(t.TempDir(), "unnamed.tar")
|
|
writeDockerSave(t, unnamed, nil, testConfig, [][]byte{layer})
|
|
if _, err := st.Load(unnamed, ""); err == nil || !strings.Contains(err.Error(), "no name") {
|
|
t.Errorf("got %v, want a no-name refusal", err)
|
|
}
|
|
loaded, err = st.Load(unnamed, "named:v2")
|
|
if err != nil || strings.Join(loaded, ",") != "named:v2" {
|
|
t.Errorf("loaded %v, %v", loaded, err)
|
|
}
|
|
|
|
// An existing image is not replaced.
|
|
if _, err := st.Load(tagged, ""); err == nil || !strings.Contains(err.Error(), "already exists") {
|
|
t.Errorf("got %v, want an already-exists refusal", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadReadsAPlainOCILayoutAndPicksTheHostPlatform(t *testing.T) {
|
|
skipWithoutTar(t)
|
|
layer := gzipBytes(t, tarBytes(t, []tarEntry{{name: "hello", content: "from oci"}}))
|
|
config := testConfig
|
|
otherConfig := strings.Replace(testConfig, "amd64", "s390x", 1)
|
|
manifestFor := func(cfg string) string {
|
|
return `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",
|
|
"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:` + cfg + `"},
|
|
"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:layer1"}]}`
|
|
}
|
|
index := `{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:nested",
|
|
"annotations":{"org.opencontainers.image.ref.name":"oci/app:2"}}]}`
|
|
nested := `{"schemaVersion":2,"manifests":[
|
|
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:m-other","platform":{"os":"linux","architecture":"s390x"}},
|
|
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:m-host","platform":{"os":"linux","architecture":"amd64"}}]}`
|
|
files := []tarEntry{
|
|
{name: "oci-layout", content: `{"imageLayoutVersion":"1.0.0"}`},
|
|
{name: "index.json", content: index},
|
|
{name: "blobs/sha256/nested", content: nested},
|
|
{name: "blobs/sha256/m-host", content: manifestFor("cfg-host")},
|
|
{name: "blobs/sha256/m-other", content: manifestFor("cfg-other")},
|
|
{name: "blobs/sha256/cfg-host", content: config},
|
|
{name: "blobs/sha256/cfg-other", content: otherConfig},
|
|
{name: "blobs/sha256/layer1", content: string(layer)},
|
|
}
|
|
archive := filepath.Join(t.TempDir(), "oci.tar")
|
|
if err := os.WriteFile(archive, tarBytes(t, files), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
loaded, err := st.Load(archive, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(loaded, ",") != "oci/app:2" {
|
|
t.Errorf("loaded %v", loaded)
|
|
}
|
|
rootfs, _ := st.RootFS("oci/app:2")
|
|
if raw, err := os.ReadFile(filepath.Join(rootfs, "hello")); err != nil || string(raw) != "from oci" {
|
|
t.Errorf("hello: %q %v", raw, err)
|
|
}
|
|
meta, _ := st.Meta("oci/app:2")
|
|
if meta.Architecture != "amd64" {
|
|
t.Errorf("picked the wrong platform: %+v", meta)
|
|
}
|
|
|
|
// An index without the host platform names what it has.
|
|
noHost := strings.Replace(nested, `"amd64"`, `"arm64"`, 1)
|
|
files[2].content = noHost
|
|
if err := os.WriteFile(archive, tarBytes(t, files), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = Store{Root: t.TempDir()}.Load(archive, "")
|
|
if err == nil || !strings.Contains(err.Error(), "available: linux/arm64, linux/s390x") {
|
|
t.Errorf("got %v, want the available platforms", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadRefusesAPlainRootfsArchive(t *testing.T) {
|
|
skipWithoutTar(t)
|
|
archive := filepath.Join(t.TempDir(), "rootfs.tar.gz")
|
|
writeTestArchive(t, archive)
|
|
_, err := Store{Root: t.TempDir()}.Load(archive, "")
|
|
if err == nil || !strings.Contains(err.Error(), "werkdock import") {
|
|
t.Errorf("got %v, want a pointer to import", err)
|
|
}
|
|
}
|