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
127 lines
3.4 KiB
Go
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
|
|
}
|
|
}
|