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

127 lines
3.4 KiB
Go

package engine
import (
"os"
"path"
"path/filepath"
"strings"
"werkdock/internal/rootpath"
)
// Docker's exit codes for a command that cannot be started at all.
const (
ExitNotExecutable = 126
ExitNotFound = 127
)
// CommandError says the command cannot be started: Code is 127 when no
// executable of that name exists in the sandbox, 126 when one exists
// but cannot be executed.
type CommandError struct {
Code int
Message string
}
func (e *CommandError) Error() string { return e.Message }
// CheckCommand looks the command up the way execvp in the sandbox will:
// the name itself when it holds a slash, else every PATH directory in
// order, a candidate that is there but not executable skipped for a
// later one. Without it bwrap answers a command it cannot execute with
// its own exit code 1, which the caller cannot tell from the command's
// own failure. What a mount brings in is not known before the sandbox
// starts, so a candidate under a mount, or one the rootfs cannot
// resolve, passes unchecked.
func CheckCommand(spec RunSpec) error {
name := spec.Command[0]
denied := ""
for _, candidate := range commandCandidates(spec, name) {
switch commandStateOf(spec, candidate) {
case commandRunnable, commandUnknown:
return nil
case commandNotExecutable:
if denied == "" {
denied = candidate
}
}
}
switch {
case denied != "":
return &CommandError{Code: ExitNotExecutable, Message: denied + ": permission denied"}
case strings.Contains(name, "/"):
return &CommandError{Code: ExitNotFound, Message: name + ": no such file or directory"}
default:
return &CommandError{Code: ExitNotFound, Message: name + ": executable file not found in $PATH"}
}
}
type commandState int
const (
commandMissing commandState = iota
commandNotExecutable
commandRunnable
commandUnknown
)
// commandCandidates are the absolute in-sandbox paths execvp tries for
// name, relative ones taken from the working directory.
func commandCandidates(spec RunSpec, name string) []string {
workdir := spec.Workdir
if workdir == "" {
workdir = "/"
}
if strings.Contains(name, "/") {
return []string{inSandbox(workdir, name)}
}
var candidates []string
for _, dir := range filepath.SplitList(sandboxPATH(spec)) {
candidates = append(candidates, inSandbox(workdir, path.Join(dir, name)))
}
return candidates
}
func inSandbox(workdir, name string) string {
if path.IsAbs(name) {
return path.Clean(name)
}
return path.Join(workdir, name)
}
// sandboxPATH is the PATH the command sees: werkdock's default, unless
// an environment variable sets it; the last one wins, as in Argv.
func sandboxPATH(spec RunSpec) string {
value := DefaultPATH
for _, e := range spec.Env {
if e.Key == "PATH" {
value = e.Value
}
}
return value
}
// commandStateOf says what the sandbox will find at candidate.
func commandStateOf(spec RunSpec, candidate string) commandState {
if _, ok := coveringMount(spec, candidate); ok {
return commandUnknown
}
host, err := rootpath.Resolve(spec.RootFS, candidate)
if err != nil {
return commandUnknown
}
// a symlink may have led into a mount
if _, ok := coveringMount(spec, "/"+strings.TrimPrefix(strings.TrimPrefix(host, filepath.Clean(spec.RootFS)), "/")); ok {
return commandUnknown
}
fi, err := os.Stat(host)
switch {
case err != nil:
return commandMissing
case fi.IsDir() || fi.Mode().Perm()&0o111 == 0:
return commandNotExecutable
default:
return commandRunnable
}
}