Files
miandmhoennig ea30cbe79c
werkator build succeeded after 00:04
One import per image at a time, and run answers 126/127 for a command that cannot start (0.11.0) (#13)
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
2026-10-05 17:29:20 +02:00

72 lines
2.8 KiB
Go

package engine
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestCheckCommandAnswersLikeExecvp(t *testing.T) {
rootfs := t.TempDir()
for dir, mode := range map[string]os.FileMode{"usr/bin": 0o755, "usr/local/bin": 0o755, "opt/bin": 0o755} {
if err := os.MkdirAll(filepath.Join(rootfs, dir), mode); err != nil {
t.Fatal(err)
}
}
for file, mode := range map[string]os.FileMode{
"usr/bin/busybox": 0o755,
"usr/bin/dash": 0o755,
"usr/bin/notes": 0o644,
"usr/local/bin/tool": 0o644, // shadowed by an executable later in PATH
"usr/bin/tool": 0o755,
"usr/local/bin/broken": 0o644,
} {
if err := os.WriteFile(filepath.Join(rootfs, file), []byte("#!/bin/sh\n"), mode); err != nil {
t.Fatal(err)
}
}
// merged /usr with a relative link, and a busybox applet with an absolute one
for link, target := range map[string]string{"bin": "usr/bin", "usr/bin/sh": "dash", "usr/bin/ash": "/bin/busybox"} {
if err := os.Symlink(target, filepath.Join(rootfs, link)); err != nil {
t.Fatal(err)
}
}
work := []Mount{{Mode: MountBind, Source: t.TempDir(), Dest: "/work"}}
tests := []struct {
name string
command string
workdir string
env []EnvVar
mounts []Mount
wantCode int // 0: runnable or not known before the start
}{
{"a name found in PATH", "sh", "", nil, nil, 0},
{"an absolute path through the merged-usr link", "/bin/sh", "", nil, nil, 0},
{"an absolute symlink resolved in the rootfs, not on the host", "ash", "", nil, nil, 0},
{"a name nowhere in PATH", "missing", "", nil, nil, ExitNotFound},
{"a path that does not exist", "/bin/missing", "", nil, nil, ExitNotFound},
{"a file without an execute bit", "notes", "", nil, nil, ExitNotExecutable},
{"a directory", "/usr/bin", "", nil, nil, ExitNotExecutable},
{"an executable later in PATH wins over a non-executable earlier", "tool", "", nil, nil, 0},
{"a non-executable and nothing after it", "broken", "", nil, nil, ExitNotExecutable},
{"a path under a mount is not checked", "/work/build.sh", "", nil, work, 0},
{"a relative path from a mounted workdir is not checked", "./build.sh", "/work", nil, work, 0},
{"a path in an implicit tmpfs is not checked", "/tmp/x", "", nil, nil, 0},
{"the image's PATH replaces the default", "sh", "", []EnvVar{{Key: "PATH", Value: "/opt/bin"}}, nil, ExitNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
spec := RunSpec{RootFS: rootfs, Command: []string{tt.command}, Workdir: tt.workdir, Env: tt.env, Mounts: tt.mounts}
err := CheckCommand(spec)
var commandErr *CommandError
switch {
case tt.wantCode == 0 && err != nil:
t.Errorf("got %v, want the command to pass", err)
case tt.wantCode != 0 && (!errors.As(err, &commandErr) || commandErr.Code != tt.wantCode):
t.Errorf("got %v, want exit code %d", err, tt.wantCode)
}
})
}
}