Phase 1: imagepull package — pull, flatten, ext4

New internal/imagepull/ subpackage. Three concerns, each
independently testable:

Pull (imagepull.go):
 - github.com/google/go-containerregistry's remote.Image with the
   linux/amd64 platform pinned. Anonymous pulls only for v1.
 - Layer blobs cached on disk via cache.NewFilesystemCache under
   <cacheDir>/blobs/sha256/<hex> — OCI-standard layout so
   skopeo/crane could co-exist later.
 - Eagerly touches every layer once so network errors surface at
   Pull time, not deep in Flatten.

Flatten (flatten.go):
 - Replays layers oldest-first into destDir.
 - Whiteout-aware: .wh.<name> deletes the named entry,
   .wh..wh..opq wipes the parent directory's contents from prior
   layers.
 - Path-traversal hardening mirrored from kernelcat extractTar:
   reject .., absolute paths, and symlinks/hardlinks whose
   resolved target escapes destDir.
 - Handles tar.TypeReg, TypeDir, TypeSymlink, TypeLink. Skips
   device/fifo nodes silently (need privilege; udev/devtmpfs
   handles them in the guest).

BuildExt4 (ext4.go):
 - Truncates outFile to sizeBytes, then runs `mkfs.ext4 -F -d
   <srcDir> -E root_owner=0:0`. No mount, no sudo, no loopback.
 - 64 MiB floor; callers handle real sizing with content-aware
   headroom.
 - File ownership in the resulting ext4 reflects srcDir's on-disk
   ownership — runner's uid/gid since extraction was unprivileged.
   Documented in package doc as a Phase A v1 limitation; Phase B
   will add a debugfs- or tar2ext4-based ownership fixup.

paths.Layout gains OCICacheDir at $XDG_CACHE_HOME/banger/oci/,
ensured at startup alongside the other dirs.

Tests use go-containerregistry's in-process registry to push and
pull synthetic multi-layer images. Cover: layer caching round-trip,
whiteout + opaque-marker handling, path-traversal rejection, unsafe
symlink rejection, real mkfs.ext4 round-trip (skipped if mkfs.ext4
absent), and tiny-size rejection.

go-containerregistry v0.21.5 added as a direct dep, plus its
transitive closure (containerd/stargz, opencontainers/go-digest,
docker/cli config helpers, etc).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-16 17:22:13 -03:00
parent da4a6bf45b
commit 78376ba6ec
No known key found for this signature in database
GPG key ID: 33112E6833C34679
7 changed files with 733 additions and 33 deletions

View file

@ -0,0 +1,70 @@
package imagepull
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"banger/internal/system"
)
// MinExt4Size is the smallest ext4 image we'll create. mkfs.ext4 needs a
// few megabytes for its bookkeeping; for a real rootfs the staging tree
// will dominate anyway.
const MinExt4Size int64 = 1 << 20 * 64 // 64 MiB
// BuildExt4 creates outFile as a sparse ext4 image of sizeBytes and
// populates it from srcDir using `mkfs.ext4 -F -d`. No mount, no sudo.
//
// sizeBytes must be at least MinExt4Size. Callers are expected to size
// the file with headroom over the staged tree (the daemon orchestrator
// does this; this function only enforces a sanity floor).
//
// The resulting image's file ownership reflects srcDir's on-disk
// ownership — see the package doc for the implications.
func BuildExt4(ctx context.Context, runner system.CommandRunner, srcDir, outFile string, sizeBytes int64) error {
if sizeBytes < MinExt4Size {
return fmt.Errorf("ext4 size %d below minimum %d", sizeBytes, MinExt4Size)
}
info, err := os.Stat(srcDir)
if err != nil {
return fmt.Errorf("stat source: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", srcDir)
}
if err := os.Remove(outFile); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
if err != nil {
return err
}
if err := f.Truncate(sizeBytes); err != nil {
_ = f.Close()
_ = os.Remove(outFile)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(outFile)
return err
}
out, runErr := runner.Run(ctx, "mkfs.ext4",
"-F",
"-q",
"-d", srcDir,
"-L", "banger-rootfs",
"-E", "root_owner=0:0",
outFile,
strconv.FormatInt(sizeBytes/4096, 10), // size in 4 KiB blocks
)
if runErr != nil {
_ = os.Remove(outFile)
return fmt.Errorf("mkfs.ext4 -d: %w: %s", runErr, string(out))
}
return nil
}

View file

@ -0,0 +1,201 @@
package imagepull
import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const (
whiteoutPrefix = ".wh."
// whiteoutOpaque marks the parent directory as opaque: every entry
// from previous layers should be removed, but entries from the
// current layer (siblings of this marker) are preserved.
whiteoutOpaque = ".wh..wh..opq"
)
// Flatten replays the image's layers in oldest-first order into destDir.
// destDir must exist and ideally be empty. Path-traversal members and
// symlink targets that escape destDir are rejected.
//
// File ownership in destDir reflects the running user, not the tar
// header's uid/gid (Phase A v1 limitation; see package docs).
func Flatten(ctx context.Context, img PulledImage, destDir string) error {
absDest, err := filepath.Abs(destDir)
if err != nil {
return err
}
layers, err := img.Image.Layers()
if err != nil {
return fmt.Errorf("read layers: %w", err)
}
for i, layer := range layers {
if err := ctx.Err(); err != nil {
return err
}
if err := applyLayer(layer, absDest); err != nil {
return fmt.Errorf("apply layer %d/%d: %w", i+1, len(layers), err)
}
}
return nil
}
func applyLayer(layer interface {
Uncompressed() (io.ReadCloser, error)
}, dest string) error {
rc, err := layer.Uncompressed()
if err != nil {
return err
}
defer rc.Close()
tr := tar.NewReader(rc)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
if err := applyEntry(tr, hdr, dest); err != nil {
return err
}
}
}
func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
rel := filepath.Clean(hdr.Name)
if rel == "." || rel == string(filepath.Separator) {
return nil
}
if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe path in layer: %q", hdr.Name)
}
base := filepath.Base(rel)
parent := filepath.Dir(rel)
// Whiteouts come in two flavors: opaque-dir markers and per-file
// deletes. Both are resolved relative to the parent directory.
if base == whiteoutOpaque {
parentAbs, err := safeJoin(dest, parent)
if err != nil {
return err
}
return clearDirContents(parentAbs)
}
if strings.HasPrefix(base, whiteoutPrefix) {
target := strings.TrimPrefix(base, whiteoutPrefix)
victim, err := safeJoin(dest, filepath.Join(parent, target))
if err != nil {
return err
}
if err := os.RemoveAll(victim); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("apply whiteout %s: %w", hdr.Name, err)
}
return nil
}
abs, err := safeJoin(dest, rel)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
return os.MkdirAll(abs, 0o755)
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
// Replace any prior file/dir in this slot — later layers
// shadow earlier ones.
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
f, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)|0o600)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
_ = f.Close()
return err
}
return f.Close()
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
// Resolve the link target relative to the link's parent and
// require that it stays inside dest. Absolute targets that
// resolve outside dest are also rejected.
resolved := hdr.Linkname
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(filepath.Dir(abs), resolved)
}
resolved = filepath.Clean(resolved)
if resolved != dest && !strings.HasPrefix(resolved, dest+string(filepath.Separator)) {
return fmt.Errorf("unsafe symlink in layer: %q -> %q", hdr.Name, hdr.Linkname)
}
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Symlink(hdr.Linkname, abs)
case tar.TypeLink:
// Hardlink: target must already exist inside dest from this or
// a previous layer, and must not escape.
linkTarget, err := safeJoin(dest, filepath.Clean(hdr.Linkname))
if err != nil {
return err
}
if _, err := os.Lstat(linkTarget); err != nil {
return fmt.Errorf("hardlink target %q missing: %w", hdr.Linkname, err)
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Link(linkTarget, abs)
default:
// TypeChar / TypeBlock / TypeFifo / TypeXGlobalHeader / etc.
// Container layers occasionally include /dev nodes — they need
// privilege we don't have. Skip silently; udev/devtmpfs in the
// guest will create them at boot.
return nil
}
}
// safeJoin returns dest+rel after verifying the result lies under dest.
func safeJoin(dest, rel string) (string, error) {
joined := filepath.Join(dest, rel)
if joined != dest && !strings.HasPrefix(joined, dest+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path: %q escapes %q", rel, dest)
}
return joined, nil
}
// clearDirContents removes every entry under dir but leaves dir itself.
// Used for opaque-whiteout markers.
func clearDirContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return os.MkdirAll(dir, 0o755)
}
return err
}
for _, entry := range entries {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,102 @@
// Package imagepull pulls OCI container images from registries and lays
// them down as banger-ready ext4 rootfs files. The package is a primitive:
// it produces an ext4 file plus per-file ownership metadata. Higher layers
// (the daemon's PullImage orchestrator) decide where the file lands and
// how it gets registered.
//
// Three concerns:
// - Pull resolves an OCI reference, selects the linux/amd64 platform,
// and returns a v1.Image whose layer blobs are cached on disk so
// re-pulls are cheap.
// - Flatten replays the layers in order into a staging directory,
// applies whiteouts, and rejects unsafe paths/symlinks.
// - BuildExt4 turns that staging directory into an ext4 file via
// `mkfs.ext4 -d` (no mount, no sudo).
//
// Limitations (Phase A v1):
// - Anonymous registry pulls only. Auth is deferred.
// - Hardcoded linux/amd64. Other platforms reject at Pull time.
// - File ownership in the resulting ext4 is the runner's uid/gid;
// setuid binaries and root-owned config files lose their original
// ownership. Phase B will add a debugfs- or tar2ext4-based fixup
// pass; until then the produced image is suitable as input to
// `image build` but not directly bootable.
package imagepull
import (
"context"
"fmt"
"os"
"path/filepath"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/cache"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/name"
)
// Platform is the only platform Phase A produces. Adding arm64 later is a
// matter of letting callers override this.
var Platform = v1.Platform{OS: "linux", Architecture: "amd64"}
// PulledImage is what Pull returns: the resolved OCI image plus enough
// reference metadata to identify it later (digest for cache keys,
// canonical name for logs).
type PulledImage struct {
Reference string // user-supplied reference, parsed and re-stringified
Digest string // image manifest digest (sha256:...)
Platform string // "linux/amd64"
Image v1.Image // go-containerregistry handle; layers, manifest, etc.
}
// Pull resolves ref against the public registry, selects the linux/amd64
// platform from any manifest list, and ensures the layer blobs are cached
// on disk under cacheDir/blobs/sha256/<hex>. Subsequent Pulls of the same
// digest are local-only.
func Pull(ctx context.Context, ref, cacheDir string) (PulledImage, error) {
parsed, err := name.ParseReference(ref)
if err != nil {
return PulledImage{}, fmt.Errorf("parse oci ref %q: %w", ref, err)
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return PulledImage{}, err
}
img, err := remote.Image(parsed,
remote.WithContext(ctx),
remote.WithPlatform(Platform),
)
if err != nil {
return PulledImage{}, fmt.Errorf("fetch %q: %w", ref, err)
}
cached := cache.Image(img, cache.NewFilesystemCache(filepath.Join(cacheDir, "blobs")))
digest, err := cached.Digest()
if err != nil {
return PulledImage{}, fmt.Errorf("resolve digest for %q: %w", ref, err)
}
// Touch the layers once so they are guaranteed present in the cache
// before Flatten runs; surfaces network errors here, not deep inside
// Flatten's hot loop.
layers, err := cached.Layers()
if err != nil {
return PulledImage{}, fmt.Errorf("read layers for %q: %w", ref, err)
}
for i, layer := range layers {
rc, err := layer.Compressed()
if err != nil {
return PulledImage{}, fmt.Errorf("fetch layer %d for %q: %w", i, ref, err)
}
_ = rc.Close()
}
return PulledImage{
Reference: parsed.String(),
Digest: digest.String(),
Platform: Platform.OS + "/" + Platform.Architecture,
Image: cached,
}, nil
}

View file

@ -0,0 +1,298 @@
package imagepull
import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
"log"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"banger/internal/system"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
)
// tarMember is a single entry to put into a fake layer tarball.
type tarMember struct {
name string
mode int64
body []byte
link string // for symlinks / hardlinks
dir bool
symlink bool
hardlink bool
}
func buildTar(t *testing.T, members []tarMember) []byte {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, m := range members {
hdr := &tar.Header{Name: m.name, Mode: m.mode}
switch {
case m.dir:
hdr.Typeflag = tar.TypeDir
if hdr.Mode == 0 {
hdr.Mode = 0o755
}
case m.symlink:
hdr.Typeflag = tar.TypeSymlink
hdr.Linkname = m.link
case m.hardlink:
hdr.Typeflag = tar.TypeLink
hdr.Linkname = m.link
default:
hdr.Typeflag = tar.TypeReg
hdr.Size = int64(len(m.body))
if hdr.Mode == 0 {
hdr.Mode = 0o644
}
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("tar header: %v", err)
}
if hdr.Typeflag == tar.TypeReg && len(m.body) > 0 {
if _, err := tw.Write(m.body); err != nil {
t.Fatalf("tar write: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("tar close: %v", err)
}
return buf.Bytes()
}
func startRegistry(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(registry.New(registry.Logger(log.New(io.Discard, "", 0))))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
return u.Host
}
func makeLayer(t *testing.T, members []tarMember) v1.Layer {
t.Helper()
body := buildTar(t, members)
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
})
if err != nil {
t.Fatalf("LayerFromOpener: %v", err)
}
return layer
}
// pushImage assembles a multi-layer image with linux/amd64 platform and
// pushes it under repo:tag. Returns the canonical reference.
func pushImage(t *testing.T, host, repo, tag string, layers ...v1.Layer) string {
t.Helper()
img, err := mutate.AppendLayers(empty.Image, layers...)
if err != nil {
t.Fatalf("AppendLayers: %v", err)
}
cfg, err := img.ConfigFile()
if err != nil {
t.Fatalf("ConfigFile: %v", err)
}
cfg.Architecture = "amd64"
cfg.OS = "linux"
img, err = mutate.ConfigFile(img, cfg)
if err != nil {
t.Fatalf("ConfigFile mutate: %v", err)
}
ref, err := name.NewTag(host + "/" + repo + ":" + tag)
if err != nil {
t.Fatalf("NewTag: %v", err)
}
if err := remote.Write(ref, img); err != nil {
t.Fatalf("remote.Write: %v", err)
}
return ref.String()
}
func TestPullCachesLayersAndReturnsImage(t *testing.T) {
host := startRegistry(t)
ref := pushImage(t, host, "banger/test", "v1",
makeLayer(t, []tarMember{
{name: "etc/", dir: true},
{name: "etc/hello", body: []byte("world")},
}),
)
cacheDir := t.TempDir()
pulled, err := Pull(context.Background(), ref, cacheDir)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if pulled.Digest == "" {
t.Fatalf("Digest empty")
}
if pulled.Platform != "linux/amd64" {
t.Fatalf("Platform = %q", pulled.Platform)
}
// Cache should now hold at least one blob.
blobsRoot := filepath.Join(cacheDir, "blobs")
count := 0
_ = filepath.WalkDir(blobsRoot, func(_ string, d os.DirEntry, _ error) error {
if d != nil && !d.IsDir() {
count++
}
return nil
})
if count == 0 {
t.Fatalf("no blobs cached under %s", blobsRoot)
}
}
func TestFlattenAppliesLayersAndWhiteouts(t *testing.T) {
host := startRegistry(t)
ref := pushImage(t, host, "banger/test", "wh",
makeLayer(t, []tarMember{
{name: "etc/", dir: true},
{name: "etc/keep", body: []byte("keep")},
{name: "etc/old", body: []byte("old")},
}),
makeLayer(t, []tarMember{
{name: "etc/.wh.old"}, // delete etc/old
{name: "etc/new", body: []byte("new")}, // add etc/new
{name: "var/", dir: true},
{name: "var/log/", dir: true},
{name: "var/log/file", body: []byte("log")},
}),
makeLayer(t, []tarMember{
{name: "var/log/.wh..wh..opq"}, // wipe var/log contents from prior layers
{name: "var/log/fresh", body: []byte("fresh")},
}),
)
pulled, err := Pull(context.Background(), ref, t.TempDir())
if err != nil {
t.Fatalf("Pull: %v", err)
}
dest := t.TempDir()
if err := Flatten(context.Background(), pulled, dest); err != nil {
t.Fatalf("Flatten: %v", err)
}
checkFile := func(rel, want string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(dest, rel))
if err != nil {
t.Errorf("read %s: %v", rel, err)
return
}
if string(data) != want {
t.Errorf("%s = %q, want %q", rel, string(data), want)
}
}
checkFile("etc/keep", "keep")
checkFile("etc/new", "new")
checkFile("var/log/fresh", "fresh")
if _, err := os.Stat(filepath.Join(dest, "etc/old")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("etc/old should have been whited out: stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(dest, "var/log/file")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("var/log/file should have been wiped by opaque marker: stat err=%v", err)
}
}
func TestFlattenRejectsPathTraversal(t *testing.T) {
host := startRegistry(t)
ref := pushImage(t, host, "banger/test", "evil",
makeLayer(t, []tarMember{
{name: "../escape", body: []byte("bad")},
}),
)
pulled, err := Pull(context.Background(), ref, t.TempDir())
if err != nil {
t.Fatalf("Pull: %v", err)
}
dest := t.TempDir()
err = Flatten(context.Background(), pulled, dest)
if err == nil || !strings.Contains(err.Error(), "unsafe path") {
t.Fatalf("Flatten escape: err=%v, want unsafe path", err)
}
escape := filepath.Join(filepath.Dir(dest), "escape")
if _, statErr := os.Stat(escape); !errors.Is(statErr, os.ErrNotExist) {
t.Errorf("escape file should not exist: %v", statErr)
}
}
func TestFlattenRejectsUnsafeSymlink(t *testing.T) {
host := startRegistry(t)
ref := pushImage(t, host, "banger/test", "evil-sym",
makeLayer(t, []tarMember{
{name: "evil", symlink: true, link: "/etc/passwd"}, // absolute target outside dest
}),
)
pulled, err := Pull(context.Background(), ref, t.TempDir())
if err != nil {
t.Fatalf("Pull: %v", err)
}
err = Flatten(context.Background(), pulled, t.TempDir())
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
t.Fatalf("Flatten unsafe symlink: err=%v", err)
}
}
func TestBuildExt4ProducesValidImage(t *testing.T) {
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
t.Skip("mkfs.ext4 not available; skipping")
}
src := t.TempDir()
if err := os.MkdirAll(filepath.Join(src, "etc"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "etc", "hello"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "rootfs.ext4")
if err := BuildExt4(context.Background(), system.NewRunner(), src, out, MinExt4Size); err != nil {
t.Fatalf("BuildExt4: %v", err)
}
info, err := os.Stat(out)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if info.Size() != MinExt4Size {
t.Errorf("ext4 size = %d, want %d", info.Size(), MinExt4Size)
}
// Quick sanity via file(1) — the ext4 superblock should be detectable.
if _, err := exec.LookPath("file"); err == nil {
out, _ := exec.Command("file", "-b", out).Output()
if !bytes.Contains(out, []byte("ext")) {
t.Errorf("file(1) does not see an ext filesystem: %s", out)
}
}
}
func TestBuildExt4RejectsTinySize(t *testing.T) {
src := t.TempDir()
out := filepath.Join(t.TempDir(), "rootfs.ext4")
err := BuildExt4(context.Background(), system.NewRunner(), src, out, 1024)
if err == nil || !strings.Contains(err.Error(), "below minimum") {
t.Fatalf("BuildExt4 tiny: err=%v", err)
}
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
t.Errorf("output file should not exist on rejection: %v", statErr)
}
}

View file

@ -23,6 +23,7 @@ type Layout struct {
VMsDir string
ImagesDir string
KernelsDir string
OCICacheDir string
}
func Resolve() (Layout, error) {
@ -54,11 +55,12 @@ func Resolve() (Layout, error) {
layout.VMsDir = filepath.Join(layout.StateDir, "vms")
layout.ImagesDir = filepath.Join(layout.StateDir, "images")
layout.KernelsDir = filepath.Join(layout.StateDir, "kernels")
layout.OCICacheDir = filepath.Join(layout.CacheDir, "oci")
return layout, nil
}
func Ensure(layout Layout) error {
for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir} {
for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir, layout.OCICacheDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}