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
209 lines
5.2 KiB
Go
209 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"werkdock/internal/rootpath"
|
|
)
|
|
|
|
// applyLayer extracts one OCI/docker layer tar onto rootfs, honoring the
|
|
// whiteout convention: `.wh.NAME` removes NAME from the lower layers,
|
|
// `.wh..wh..opq` empties the directory it sits in. Whiteouts are applied
|
|
// before the layer's own entries, so an opaque directory refilled by the
|
|
// same layer ends up with exactly that layer's content.
|
|
//
|
|
// Every path is resolved inside rootfs the way the sandbox will see it:
|
|
// a lower layer's symlink (/bin -> usr/bin) is followed within rootfs,
|
|
// never on the host, and anything escaping rootfs is refused. Ownership
|
|
// is dropped, device nodes are skipped; the sandbox maps uid 0 to the
|
|
// caller anyway.
|
|
func applyLayer(rootfs, layerPath string) error {
|
|
if err := forEachEntry(layerPath, func(hdr *tar.Header, _ io.Reader) error {
|
|
return applyWhiteout(rootfs, hdr.Name)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return forEachEntry(layerPath, func(hdr *tar.Header, body io.Reader) error {
|
|
if strings.HasPrefix(path.Base(hdr.Name), whiteoutPrefix) {
|
|
return nil
|
|
}
|
|
return extractEntry(rootfs, hdr, body)
|
|
})
|
|
}
|
|
|
|
const (
|
|
whiteoutPrefix = ".wh."
|
|
opaqueWhiteout = ".wh..wh..opq"
|
|
)
|
|
|
|
func applyWhiteout(rootfs, name string) error {
|
|
base := path.Base(name)
|
|
if !strings.HasPrefix(base, whiteoutPrefix) {
|
|
return nil
|
|
}
|
|
dir, err := rootpath.Resolve(rootfs, path.Dir(name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if base == opaqueWhiteout {
|
|
entries, err := os.ReadDir(dir)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return os.RemoveAll(filepath.Join(dir, strings.TrimPrefix(base, whiteoutPrefix)))
|
|
}
|
|
|
|
func extractEntry(rootfs string, hdr *tar.Header, body io.Reader) error {
|
|
name := path.Clean("/" + hdr.Name)
|
|
if name == "/" {
|
|
return nil
|
|
}
|
|
parent, err := rootpath.Resolve(rootfs, path.Dir(name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(parent, path.Base(name))
|
|
mode := os.FileMode(hdr.Mode) & (os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky)
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
if fi, err := os.Lstat(target); err == nil && !fi.IsDir() {
|
|
if err := os.RemoveAll(target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Directories stay owner-writable so later layers can add to
|
|
// them; the rootfs is bound read-only into the sandbox anyway.
|
|
return os.Chmod(target, mode|0o700)
|
|
case tar.TypeReg, tar.TypeRegA:
|
|
if err := removeIfExists(target); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(f, body); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Chmod(target, mode)
|
|
case tar.TypeSymlink:
|
|
if err := removeIfExists(target); err != nil {
|
|
return err
|
|
}
|
|
return os.Symlink(hdr.Linkname, target)
|
|
case tar.TypeLink:
|
|
src, err := rootpath.Resolve(rootfs, "/"+hdr.Linkname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := removeIfExists(target); err != nil {
|
|
return err
|
|
}
|
|
return os.Link(src, target)
|
|
case tar.TypeChar, tar.TypeBlock, tar.TypeFifo:
|
|
return nil // device nodes: /dev comes from the host; fifos are not image content
|
|
case tar.TypeXGlobalHeader:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%s: unsupported tar entry type %q", hdr.Name, hdr.Typeflag)
|
|
}
|
|
}
|
|
|
|
func removeIfExists(target string) error {
|
|
if _, err := os.Lstat(target); err == nil {
|
|
return os.RemoveAll(target)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// forEachEntry streams a layer, transparently decompressing gzip
|
|
// in-process and zstd through the zstd binary.
|
|
func forEachEntry(layerPath string, fn func(*tar.Header, io.Reader) error) error {
|
|
f, err := os.Open(layerPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
r, closeFn, err := decompress(f)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", layerPath, err)
|
|
}
|
|
defer closeFn()
|
|
tr := tar.NewReader(r)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", layerPath, err)
|
|
}
|
|
if err := fn(hdr, tr); err != nil {
|
|
return fmt.Errorf("%s: %s: %w", filepath.Base(layerPath), hdr.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var (
|
|
gzipMagic = []byte{0x1f, 0x8b}
|
|
zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd}
|
|
)
|
|
|
|
func decompress(f *os.File) (io.Reader, func(), error) {
|
|
br := bufio.NewReader(f)
|
|
head, _ := br.Peek(4)
|
|
switch {
|
|
case bytes.HasPrefix(head, gzipMagic):
|
|
gz, err := gzip.NewReader(br)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return gz, func() { gz.Close() }, nil
|
|
case bytes.HasPrefix(head, zstdMagic):
|
|
cmd := exec.Command("zstd", "-dc")
|
|
cmd.Stdin = br
|
|
cmd.Stderr = os.Stderr
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, nil, fmt.Errorf("zstd layer needs the zstd binary: %w", err)
|
|
}
|
|
return out, func() { out.Close(); _ = cmd.Wait() }, nil
|
|
default:
|
|
return br, func() {}, nil
|
|
}
|
|
}
|