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
357 lines
11 KiB
Go
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
|
|
}
|