Files
6d6060bb07 RFC 0005 milestone 2: instance lifecycle — run -d, ps, stop, logs, rm, exec (#4)
An instance is a directory under instances/<id>/ (config.json, state.json,
stdout, stderr, run/, volumes/) written by package instance, the service
behind the verbs. run -d and start fork this binary as a supervisor
(werkdock supervise ID) that records the pids and the exit code; stop,
kill, wait, logs, ps, rm, inspect read the files. exec is a second bwrap
over the same instance tree. The command is pid 1 of its sandbox
(bwrap --as-pid-1) so stop's SIGTERM reaches it, as in docker; /run and
the anonymous volumes are instance directories, kept across stop/start
and shared with exec. Version 0.5.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #4
2026-09-10 20:55:59 +02:00

357 lines
11 KiB
Go

// Package store is the on-disk image store. An image is a rootfs
// archive unpacked under the store root; instance state lives beside
// it in instances/ (package instance), in a format both the CLI and a
// later daemon read (RFC 0002, RFC 0005).
//
// Layout: images/<repository>/<tag>/{rootfs,image.json}, the repository
// path nested as written (ghcr.io/example/build:2026.09 lands in
// images/ghcr.io/example/build/2026.09). Stores written before tags
// existed hold images/<name>/{rootfs,image.json}; they are still read
// as <name>:latest, so no reload is needed after an upgrade.
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"syscall"
"time"
)
// Store is rooted at $WERKDOCK_HOME, defaulting to ~/.werkdock.
type Store struct {
Root string
}
// ImageConfig is the execution-relevant part of an OCI image
// configuration (field names as in the OCI image spec and docker
// inspect), stored with every image and honored by run.
type ImageConfig struct {
User string `json:"User,omitempty"`
Env []string `json:"Env,omitempty"`
Entrypoint []string `json:"Entrypoint,omitempty"`
Cmd []string `json:"Cmd,omitempty"`
WorkingDir string `json:"WorkingDir,omitempty"`
Volumes map[string]struct{} `json:"Volumes,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
}
// ImageMeta is written as image.json beside each image's rootfs.
// Images written before the metadata existed lack architecture, os,
// and config; they read back as zero values.
type ImageMeta struct {
Name string `json:"name"`
Source string `json:"source"`
CreatedAt time.Time `json:"createdAt"`
Architecture string `json:"architecture,omitempty"`
OS string `json:"os,omitempty"`
Config ImageConfig `json:"config"`
}
// Default resolves the store root from the environment.
func Default() (Store, error) {
if root := os.Getenv("WERKDOCK_HOME"); root != "" {
return Store{Root: root}, nil
}
home, err := os.UserHomeDir()
if err != nil {
return Store{}, fmt.Errorf("cannot resolve the store root: %w", err)
}
return Store{Root: filepath.Join(home, ".werkdock")}, nil
}
func (s Store) imagesRoot() string { return filepath.Join(s.Root, "images") }
func (s Store) imageDir(ref Reference) string {
return filepath.Join(s.imagesRoot(), filepath.FromSlash(ref.Repository), ref.Tag)
}
// isImageDir reports whether dir holds an image: metadata beside an
// unpacked rootfs.
func isImageDir(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, "image.json")); err != nil {
return false
}
fi, err := os.Stat(filepath.Join(dir, "rootfs"))
return err == nil && fi.IsDir()
}
// Dir resolves a reference to its image directory, falling back to the
// flat legacy layout for bare names.
func (s Store) Dir(reference string) (string, error) {
ref, err := ParseReference(reference)
if err != nil {
return "", err
}
dir := s.imageDir(ref)
if isImageDir(dir) {
return dir, nil
}
if legacy := filepath.Join(s.imagesRoot(), ref.Repository); ref.legacyName() && isImageDir(legacy) {
return legacy, nil
}
return "", fmt.Errorf("no such image: %s (import it with: werkdock import ARCHIVE %s)", ref.Short(), ref.Short())
}
// RootFS resolves an image reference to its unpacked rootfs directory.
func (s Store) RootFS(reference string) (string, error) {
dir, err := s.Dir(reference)
if err != nil {
return "", err
}
return filepath.Join(dir, "rootfs"), nil
}
// List returns the references of all loaded images in short form
// (the default tag omitted), sorted. Half-written `.tmp` directories
// from an interrupted import are not images.
func (s Store) List() ([]string, error) {
root := s.imagesRoot()
if _, err := os.Stat(root); os.IsNotExist(err) {
return nil, nil
}
var names []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == root || !d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), ".tmp") || strings.HasPrefix(d.Name(), ".load-") {
return fs.SkipDir
}
if isImageDir(filepath.Dir(path)) && d.Name() == "rootfs" {
return fs.SkipDir
}
if isImageDir(path) {
rel := filepath.ToSlash(strings.TrimPrefix(path, root+string(filepath.Separator)))
ref := Reference{Repository: rel, Tag: defaultTag}
if i := strings.LastIndex(rel, "/"); i >= 0 {
ref = Reference{Repository: rel[:i], Tag: rel[i+1:]}
}
names = append(names, ref.Short())
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(names)
return names, nil
}
// Import creates an image from a plain rootfs archive (docker import
// semantics; docker/OCI image archives are docker load's business and
// refused here). The archive is unpacked with the tar CLI (compression
// auto-detected; .tar.zst needs the zstd binary, which doctor checks)
// into a temporary directory and renamed into place, so a failed
// import leaves no half image behind.
func (s Store) Import(archive, reference string, config ImageConfig) error {
ref, err := ParseReference(reference)
if err != nil {
return err
}
name := ref.Short()
archiveAbs, err := filepath.Abs(archive)
if err != nil {
return err
}
if _, err := os.Stat(archiveAbs); err != nil {
return fmt.Errorf("archive: %w", err)
}
if _, err := s.Dir(reference); err == nil {
return fmt.Errorf("image %q already exists (werkdock rmi %s to replace it)", name, name)
}
dir := s.imageDir(ref)
tmp := dir + ".tmp"
if err := os.RemoveAll(tmp); err != nil {
return err
}
meta := ImageMeta{
Name: name,
Source: archiveAbs,
CreatedAt: time.Now().UTC(),
Architecture: runtime.GOARCH,
OS: runtime.GOOS,
Config: config,
}
if err := s.unpackInto(tmp, archiveAbs, meta); err != nil {
_ = os.RemoveAll(tmp)
s.pruneEmptyParents(tmp)
return err
}
if err := os.Rename(tmp, dir); err != nil {
_ = os.RemoveAll(tmp)
s.pruneEmptyParents(tmp)
return err
}
return nil
}
func (s Store) unpackInto(tmp, archiveAbs string, meta ImageMeta) error {
rootfs := filepath.Join(tmp, "rootfs")
if err := untar(archiveAbs, rootfs); err != nil {
return err
}
if kind := imageArchiveKind(rootfs); kind != "" {
return fmt.Errorf("%s is a %s image archive, not a rootfs archive; werkdock load will handle those (not implemented yet)", archiveAbs, kind)
}
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(tmp, "image.json"), append(raw, '\n'), 0o644)
}
// Meta reads an image's metadata.
func (s Store) Meta(reference string) (ImageMeta, error) {
dir, err := s.Dir(reference)
if err != nil {
return ImageMeta{}, err
}
raw, err := os.ReadFile(filepath.Join(dir, "image.json"))
if err != nil {
return ImageMeta{}, err
}
var meta ImageMeta
if err := json.Unmarshal(raw, &meta); err != nil {
return ImageMeta{}, fmt.Errorf("%s: %w", filepath.Join(dir, "image.json"), err)
}
return meta, nil
}
// untar unpacks an archive with the tar CLI (compression auto-detected;
// .tar.zst needs the zstd binary, which doctor checks) into dir.
func untar(archiveAbs, dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
cmd := exec.Command("tar", "--no-same-owner", "-xf", archiveAbs, "-C", dir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unpacking %s failed: %w\n%s", archiveAbs, err, strings.TrimSpace(string(out)))
}
return nil
}
// imageArchiveKind recognizes an unpacked `docker save` archive
// (manifest.json at the top) or an OCI image layout (oci-layout beside
// index.json); a rootfs has neither. Checked after unpacking, which
// costs nothing extra in the normal case.
func imageArchiveKind(dir string) string {
exists := func(name string) bool {
_, err := os.Stat(filepath.Join(dir, name))
return err == nil
}
switch {
case exists("oci-layout") && exists("index.json"):
return "OCI"
case exists("manifest.json"):
return "docker"
}
return ""
}
// pruneEmptyParents removes the repository directories a failed import
// created above path, up to (excluding) images/, so a typo in a
// registry name leaves nothing behind.
func (s Store) pruneEmptyParents(path string) {
root := s.imagesRoot()
for dir := filepath.Dir(path); dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)); dir = filepath.Dir(dir) {
if os.Remove(dir) != nil {
return
}
}
}
// ImageNameFromArchive derives a default image name from an archive
// file name by stripping the compression and tar extensions:
// "werkator-buildenv-trixie.tar.zst" becomes "werkator-buildenv-trixie".
func ImageNameFromArchive(archive string) string {
name := filepath.Base(archive)
for {
ext := filepath.Ext(name)
switch strings.ToLower(ext) {
case ".tar", ".gz", ".tgz", ".zst", ".xz", ".bz2":
name = strings.TrimSuffix(name, ext)
default:
return strings.ToLower(name)
}
}
}
// Remove deletes an image (docker rmi): the directory of the reference,
// legacy layout included, then the repository directories it leaves
// empty. The tree is renamed to a `.tmp` name first, so a concurrent
// List never sees a half-deleted image. Returns the reference removed,
// in short form.
func (s Store) Remove(reference string) (string, error) {
dir, err := s.Dir(reference)
if err != nil {
return "", err
}
ref, _ := ParseReference(reference)
doomed := dir + ".rm.tmp"
if err := os.Rename(dir, doomed); err != nil {
return "", err
}
if err := os.RemoveAll(doomed); err != nil {
return "", err
}
s.pruneEmptyParents(dir)
return ref.Short(), nil
}
// ID is the image identifier `images` and `inspect` show: the first
// twelve hex digits of a hash over the metadata without the name, so
// two tags of the same image share it (docker's IDs come from the
// config blob, which werkdock does not keep verbatim).
func (m ImageMeta) ID() string {
m.Name = ""
raw, _ := json.Marshal(m)
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])[:12]
}
// Size sums the regular files of an image's rootfs (bytes), the way
// `docker images` reports a size. Hardlinked files inside the rootfs
// count once (busybox links 400 names to one binary); a hardlinked tag
// copy still counts in full, as a docker tag does.
func (s Store) Size(reference string) (int64, error) {
rootfs, err := s.RootFS(reference)
if err != nil {
return 0, err
}
var total int64
seen := map[uint64]bool{}
err = filepath.WalkDir(rootfs, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.Type().IsRegular() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Nlink > 1 {
if seen[st.Ino] {
return nil
}
seen[st.Ino] = true
}
total += info.Size()
return nil
})
return total, err
}