// 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///{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//{rootfs,image.json}; they are still read // as :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 }