Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2782ff7
Materialize per-layer artifacts with explicit whiteout handling
chruffins Aug 31, 2026
66dd4ae
Harden layer artifact materialization
chruffins Sep 1, 2026
3ac5b62
Address layer artifact review findings
chruffins Sep 1, 2026
7b94b90
Fix layer artifact composition edge cases
chruffins Sep 2, 2026
328ebcf
Fix layer artifact composition and metadata handling edge cases
chruffins Sep 2, 2026
77ddc9f
Harden layer artifact composition
chruffins Sep 2, 2026
afe3a56
Harden layer tree portability and file copying
chruffins Sep 3, 2026
facca70
Harden layer materialization and metadata restoration
chruffins Sep 3, 2026
23299be
Share context-aware cached layer unpacking
chruffins Sep 3, 2026
58b119d
Serialize layer store writes
chruffins Sep 3, 2026
97a087f
Preserve explicit layer directory metadata
chruffins Sep 3, 2026
11304e6
Use singleflight for layer materialization
chruffins Sep 3, 2026
fdb53e3
Test synthesized directory metadata preservation
chruffins Sep 3, 2026
0f69cbc
Collapse layer helper wrappers and dead branches
chruffins Sep 3, 2026
beaf23e
Drop access time preservation from layer metadata
chruffins Sep 3, 2026
d4ed105
Extract layers with umoci instead of a private tar walker
chruffins Sep 3, 2026
8492c74
Detach shared layer builds from the initiating context
chruffins Sep 3, 2026
812e975
Trim layer artifact plumbing
chruffins Sep 3, 2026
8730f01
Compose VM rootfs from shared layer blobs
chruffins Aug 31, 2026
a76d558
Make rootfs composition safe and cancellable
chruffins Sep 1, 2026
debe38d
Track explicit layer directories during composition
chruffins Sep 3, 2026
4124c9d
Use renamed layer unpack and apply helpers
chruffins Sep 3, 2026
4935822
Apply layers onto the staging tree directly with umoci
chruffins Sep 3, 2026
9abd3b5
Add shared-layer lifecycle management
chruffins Aug 31, 2026
878fc6b
Clean up stale image claims
chruffins Aug 31, 2026
6b7c696
Harden hypervisor and image cleanup
chruffins Sep 1, 2026
31dd71a
Fix lifecycle regression tests
chruffins Sep 1, 2026
0675317
Serialize layer reconciliation with image finalization
chruffins Sep 1, 2026
ccc7dce
Rebase storage lifecycle onto rootfs composition
chruffins Sep 3, 2026
8d90859
Include layer cache in image disk totals
chruffins Sep 3, 2026
5ca6d77
Reconcile lifecycle with context-aware layer storage
chruffins Sep 3, 2026
1fd68ef
Integrate layer materialization with lifecycle GC
chruffins Sep 3, 2026
be774c5
Use renamed materializeLayerArtifact
chruffins Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions lib/images/compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package images

import (
"context"
"fmt"
"os"
"path/filepath"
)

// composeRootfs validates the persisted model and merges its layers into dest
// in manifest order, reading each layer blob from the shared OCI cache.
// Whiteout and opaque-directory markers are interpreted as each layer is
// applied.
func (c *ociClient) composeRootfs(dest, layoutTag string, model *imageManifestModel) error {
return c.composeRootfsContext(context.Background(), dest, layoutTag, model)
}

func (c *ociClient) composeRootfsContext(ctx context.Context, dest, layoutTag string, model *imageManifestModel) error {
if err := validateManifestModel(layoutTag, model); err != nil {
return fmt.Errorf("validate manifest model: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return fmt.Errorf("create compose parent: %w", err)
}
staging, err := os.MkdirTemp(filepath.Dir(dest), ".compose-*")
if err != nil {
return fmt.Errorf("create compose directory: %w", err)
}
defer os.RemoveAll(staging)

for i, desc := range model.Layers {
if _, err := unpackCachedLayer(ctx, c.cacheDir, desc, staging, composeOnDiskFormat()); err != nil {
return fmt.Errorf("apply layer %d (%s): %w", i, desc.Digest, err)
}
}
if err := os.RemoveAll(dest); err != nil {
return fmt.Errorf("replace compose directory: %w", err)
}
if err := os.Rename(staging, dest); err != nil {
return fmt.Errorf("install compose directory: %w", err)
}
return nil
}
207 changes: 207 additions & 0 deletions lib/images/compose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package images

import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

gcr "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/kernel/hypeman/lib/paths"
"github.com/stretchr/testify/require"
)

// composeTestImage builds the standard two-layer fixture: a base layer with
// content the top layer deletes, masks, replaces, and extends.
func composeTestImage(t *testing.T) gcr.Image {
t.Helper()

base := specLayer(t, []tarEntrySpec{
{name: "etc/", isDir: true, mode: 0755},
{name: "etc/config.txt", content: "original", mode: 0644},
{name: "app/", isDir: true, mode: 0755},
{name: "app/main.txt", content: "v1", mode: 0644},
{name: "data/", isDir: true, mode: 0755},
{name: "data/old.txt", content: "stale", mode: 0644},
{name: "replacedir/", isDir: true, mode: 0755},
{name: "replacedir/inner.txt", content: "inner", mode: 0644},
})
top := specLayer(t, []tarEntrySpec{
{name: "etc/.wh.config.txt", content: "", mode: 0644},
{name: "app/main.txt", content: "v2", mode: 0644},
{name: "data/.wh..wh..opq", content: "", mode: 0644},
{name: "data/new.txt", content: "new", mode: 0644},
{name: "bin/", isDir: true, mode: 0755},
{name: "bin/tool", content: "tool", mode: 0755},
{name: "replacedir", content: "now a file", mode: 0644},
})

img, err := mutate.AppendLayers(empty.Image, base, top)
require.NoError(t, err)
return img
}

// composeFixture composes the standard fixture image into the shared OCI cache
// and returns a client plus its validated manifest model.
func composeFixture(t *testing.T, p *paths.Paths) (*ociClient, string, *imageManifestModel) {
t.Helper()

img := composeTestImage(t)
writeLayerTestLayout(t, p, img)

client, err := newOCIClient(p.SystemOCICache())
require.NoError(t, err)
digest, err := img.Digest()
require.NoError(t, err)
tag := digestToLayoutTag(digest.String())
bundle, err := client.extractOCIImageBundle(tag)
require.NoError(t, err)
return client, tag, bundle.Model
}

func TestComposeRootfsWhiteoutsAndOrdering(t *testing.T) {
p := paths.New(t.TempDir())
client, tag, model := composeFixture(t, p)
require.Len(t, model.Layers, 2)

dest := filepath.Join(t.TempDir(), "rootfs")
require.NoError(t, client.composeRootfs(dest, tag, model))

// Whiteout removed the base entry.
_, err := os.Lstat(filepath.Join(dest, "etc", "config.txt"))
require.True(t, os.IsNotExist(err), "whiteout must delete the base entry")

// Plain replacement.
data, err := os.ReadFile(filepath.Join(dest, "app", "main.txt"))
require.NoError(t, err)
require.Equal(t, "v2", string(data))

// Opaque directory masked the base content.
_, err = os.Lstat(filepath.Join(dest, "data", "old.txt"))
require.True(t, os.IsNotExist(err), "opaque marker must mask base contents")
data, err = os.ReadFile(filepath.Join(dest, "data", "new.txt"))
require.NoError(t, err)
require.Equal(t, "new", string(data))

// Directory replaced by a regular file.
info, err := os.Lstat(filepath.Join(dest, "replacedir"))
require.NoError(t, err)
require.False(t, info.IsDir())
data, err = os.ReadFile(filepath.Join(dest, "replacedir"))
require.NoError(t, err)
require.Equal(t, "now a file", string(data))

// New entry present with its mode.
info, err = os.Stat(filepath.Join(dest, "bin", "tool"))
require.NoError(t, err)
require.Equal(t, os.FileMode(0755), info.Mode().Perm())

// No whiteout markers survive composition.
require.NoError(t, filepath.Walk(dest, func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
require.NotContains(t, info.Name(), whiteoutPrefix, "whiteout marker leaked into composed rootfs")
return nil
}))
}

// zeroLayerModel returns a schema-valid manifest model with no layers.
func zeroLayerModel() *imageManifestModel {
return &imageManifestModel{
SchemaVersion: manifestModelSchemaVersion,
Digest: "sha256:" + strings.Repeat("ab", 32),
Config: manifestConfigRef{Digest: "sha256:" + strings.Repeat("cd", 32)},
Layers: make([]layerDescriptor, 0),
}
}

func TestComposeRootfsEmptyLayers(t *testing.T) {
p := paths.New(t.TempDir())
client, err := newOCIClient(p.SystemOCICache())
require.NoError(t, err)
model := zeroLayerModel()
dest := filepath.Join(t.TempDir(), "rootfs")
require.NoError(t, client.composeRootfs(dest, model.Digest, model))
entries, err := os.ReadDir(dest)
require.NoError(t, err)
require.Empty(t, entries)
}

func TestComposeRootfsInvalidModel(t *testing.T) {
p := paths.New(t.TempDir())
client, tag, model := composeFixture(t, p)

model.Config.DiffIDs = model.Config.DiffIDs[:1]
err := client.composeRootfs(filepath.Join(t.TempDir(), "rootfs"), tag, model)
require.ErrorContains(t, err, "1 diff ids for 2 layers")
}

func TestComposeRootfsMissingBlob(t *testing.T) {
p := paths.New(t.TempDir())
client, err := newOCIClient(p.SystemOCICache())
require.NoError(t, err)

digestHex := "sha256:" + strings.Repeat("ab", 32)
model := &imageManifestModel{
SchemaVersion: manifestModelSchemaVersion,
Digest: digestHex,
Config: manifestConfigRef{
Digest: "sha256:" + strings.Repeat("cd", 32),
DiffIDs: []string{"sha256:" + strings.Repeat("ef", 32)},
},
Layers: []layerDescriptor{{
Digest: "sha256:" + strings.Repeat("01", 32),
MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
DiffID: "sha256:" + strings.Repeat("ef", 32),
}},
}
err = client.composeRootfs(t.TempDir(), digestHex, model)
require.ErrorContains(t, err, "missing from oci cache")
}

func TestComposeRootfsDiffIDMismatch(t *testing.T) {
p := paths.New(t.TempDir())
client, tag, model := composeFixture(t, p)

// Replace the top layer's cached blob with different content so the
// unpacked diff id no longer matches the descriptor.
other := specLayer(t, []tarEntrySpec{{name: "other.txt", content: "other", mode: 0644}})
otherBlob, err := other.Compressed()
require.NoError(t, err)
data, err := io.ReadAll(otherBlob)
require.NoError(t, err)
topHex := strings.TrimPrefix(model.Layers[1].Digest, "sha256:")
require.NoError(t, os.WriteFile(p.OCICacheBlob(topHex), data, 0644))

err = client.composeRootfs(filepath.Join(t.TempDir(), "rootfs"), tag, model)
require.ErrorContains(t, err, "diff id mismatch")
}

// TestComposeRootfsExportsValidErofs composes the fixture image and exports it
// to erofs, then verifies the filesystem is intact and its contents match the
// composed tree.
func TestComposeRootfsExportsValidErofs(t *testing.T) {
if _, err := exec.LookPath("mkfs.erofs"); err != nil {
t.Skip("mkfs.erofs not available")
}
if _, err := exec.LookPath("fsck.erofs"); err != nil {
t.Skip("fsck.erofs not available")
}

p := paths.New(t.TempDir())
client, tag, model := composeFixture(t, p)

staging := filepath.Join(t.TempDir(), "rootfs")
require.NoError(t, client.composeRootfs(staging, tag, model))

diskPath := filepath.Join(t.TempDir(), "rootfs.erofs")
size, err := ExportRootfs(staging, diskPath, FormatErofs)
require.NoError(t, err)
require.Greater(t, size, int64(0))

output, err := exec.Command("fsck.erofs", "--extract", diskPath).CombinedOutput()
require.NoError(t, err, "fsck.erofs failed: %s", output)
}
40 changes: 39 additions & 1 deletion lib/images/disk_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package images
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"
)

Expand Down Expand Up @@ -82,6 +84,38 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) {
return total, nil
}

// totalLayerArtifactBytesFromFilesystem sums materialized layer artifacts.
func totalLayerArtifactBytesFromFilesystem(layersDir string) (int64, error) {
var total int64
err := filepath.WalkDir(layersDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() {
if strings.HasPrefix(d.Name(), ".") && path != layersDir {
return filepath.SkipDir
}
return nil
}
if !strings.HasPrefix(d.Name(), "layer.") {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if err != nil {
return 0, fmt.Errorf("walk layer artifacts: %w", err)
}
return total, nil
}

// totalOCICacheBlobBytesFromFilesystem sums blob sizes directly from the OCI cache blob store.
// This counts the actual bytes on disk, including any blob files that are currently
// present but no longer referenced by the OCI layout index.
Expand Down Expand Up @@ -158,7 +192,11 @@ func (m *manager) computeDiskUsageTotals() (int64, int64, error) {
if err != nil {
return 0, 0, err
}
return readyImageBytes, ociCacheBytes, nil
layerArtifactBytes, err := totalLayerArtifactBytesFromFilesystem(m.paths.ImageLayersDir())
if err != nil {
return 0, 0, err
}
return readyImageBytes, ociCacheBytes + layerArtifactBytes, nil
}

func totalRootfsBytesInDigestDir(digestDir string) (int64, error) {
Expand Down
20 changes: 20 additions & 0 deletions lib/images/disk_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ func TestTotalReadyImageBytesFromMetadata_DeduplicatesMalformedAliases(t *testin
require.Equal(t, int64(len("shared-rootfs")), total)
}

func TestTotalLayerArtifactBytesFromFilesystem(t *testing.T) {
t.Parallel()

layersDir := t.TempDir()
digestDir := filepath.Join(layersDir, "a", "b")
require.NoError(t, os.MkdirAll(digestDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(digestDir, "layer.erofs"), []byte("erofs"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(digestDir, "layer.ext4"), []byte("ext4"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(digestDir, "artifact.erofs.json"), []byte("record"), 0o644))

// In-progress temp dirs should be skipped.
unpackDir := filepath.Join(digestDir, ".unpack-tmp")
require.NoError(t, os.MkdirAll(unpackDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(unpackDir, "layer.bin"), []byte("unpacked-content"), 0o644))

total, err := totalLayerArtifactBytesFromFilesystem(layersDir)
require.NoError(t, err)
require.Equal(t, int64(len("erofs")+len("ext4")), total)
}

func TestTotalReadyImageBytesFromMetadata_UsesRootfsFallbackForReadyImageWithoutSize(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading