Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions lib/images/compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package images

import (
"fmt"
"os"
)

// 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 {
if err := validateManifestModel(layoutTag, model); err != nil {
return fmt.Errorf("validate manifest model: %w", err)
}
if len(model.Layers) == 0 {
return fmt.Errorf("image has no layers")
}
if err := os.MkdirAll(dest, 0755); err != nil {
return fmt.Errorf("create compose directory: %w", err)
}
for i, desc := range model.Layers {
if err := c.applyLayerToDir(dest, desc); err != nil {
return fmt.Errorf("apply layer %d (%s): %w", i, desc.Digest, err)
}
}
return nil
}

func (c *ociClient) applyLayerToDir(dest string, desc layerDescriptor) error {
layerDir, err := os.MkdirTemp("", "hypeman-layer-*")
if err != nil {
return fmt.Errorf("create layer staging directory: %w", err)
}
defer os.RemoveAll(layerDir)

if _, err := unpackCachedLayer(c.cacheDir, desc, layerDir); err != nil {
return err
}
if err := applyLayerTree(layerDir, dest); err != nil {
return fmt.Errorf("apply layer tree: %w", err)
}
return nil
}
204 changes: 204 additions & 0 deletions lib/images/compose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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()
err = client.composeRootfs(t.TempDir(), model.Digest, model)
require.ErrorContains(t, err, "no layers")
}

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)
}
47 changes: 30 additions & 17 deletions lib/images/layer_artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,34 @@ func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) {

// materializeLayerArtifact ensures a layer has a materialized artifact keyed
// by its blob digest, building it from the shared OCI cache blob when absent.
// The layer is unpacked into an isolated temp directory, converted to the
// default image format, and installed atomically; an interrupted build leaves
// only temp files that the next attempt replaces. No production caller yet:
// pull integration and composition land in later changes.
// unpackCachedLayer resolves a layer blob in the shared OCI cache and unpacks
// it into dest, verifying the unpacked diff id against the descriptor.
func unpackCachedLayer(cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) {
layerHex := strings.TrimPrefix(desc.Digest, "sha256:")
if err := paths.ValidatePathComponent(layerHex); err != nil {
return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest)
}
blobPath := filepath.Join(cacheDir, "blobs", "sha256", layerHex)
if _, err := os.Stat(blobPath); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest)
}
return nil, fmt.Errorf("stat layer blob: %w", err)
}

stats, err := unpackLayerBlob(blobPath, desc.MediaType, dest)
if err != nil {
return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err)
}
if stats.diffID != desc.DiffID {
return nil, fmt.Errorf("layer %s diff id mismatch: got %s, want %s", desc.Digest, stats.diffID, desc.DiffID)
}
return stats, nil
}

// The layer is unpacked into an isolated temp directory, converted to erofs,
// and installed atomically; an interrupted build leaves only temp files that
// the next attempt replaces.
func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) {
layerHex := strings.TrimPrefix(desc.Digest, "sha256:")
if err := paths.ValidatePathComponent(layerHex); err != nil {
Expand All @@ -145,14 +169,6 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact
// Record without artifact: rebuild below.
}

blobPath := m.paths.OCICacheBlob(layerHex)
if _, err := os.Stat(blobPath); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest)
}
return nil, fmt.Errorf("stat layer blob: %w", err)
}

layerDir := m.paths.ImageLayerDir(layerHex)
if err := os.MkdirAll(layerDir, 0755); err != nil {
return nil, fmt.Errorf("create layer directory: %w", err)
Expand All @@ -163,12 +179,9 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact
}
defer os.RemoveAll(unpackDir)

stats, err := unpackLayerBlob(blobPath, desc.MediaType, unpackDir)
stats, err := unpackCachedLayer(m.paths.SystemOCICache(), desc, unpackDir)
if err != nil {
return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err)
}
if desc.DiffID != "" && stats.diffID != desc.DiffID {
return nil, fmt.Errorf("layer %s diff id mismatch: got %s, want %s", desc.Digest, stats.diffID, desc.DiffID)
return nil, err
}

return m.installLayerArtifact(desc, layerHex, unpackDir, stats)
Expand Down
5 changes: 3 additions & 2 deletions lib/images/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,10 @@ func (c *ociClient) pullAndExportWithPlatformAuth(ctx context.Context, imageRef,
result.LayerCount = bundle.LayerCount
result.CompressedBytes = bundle.CompressedBytes

// Unpack layers to the export directory
// Compose the rootfs from the shared layer blobs in manifest order.
// composeRootfs validates the model and rejects zero-layer manifests.
if err := result.measure("layer_unpack", func() error {
return c.unpackLayers(ctx, layoutTag, exportDir)
return c.composeRootfs(exportDir, layoutTag, bundle.Model)
}); err != nil {
return result, fmt.Errorf("unpack layers: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion lib/images/recovery_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestRecoverInterruptedBuildsCapturedFixtureMarksBuildFailed(t *testing.T) {
require.NotNil(t, meta.Error)
assert.Equal(t, recoveryFixtureDigest, meta.Digest)
assert.Equal(t, StatusFailed, meta.Status)
assert.Contains(t, *meta.Error, "config rootfs.diff_ids has 0 entries but manifest has 1 layers")
assert.Contains(t, *meta.Error, "manifest model has 0 diff ids for 1 layers")
}

func copyRecoveryFixture(t *testing.T) string {
Expand Down
52 changes: 52 additions & 0 deletions lib/images/testlayers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package images

import (
"archive/tar"
"bytes"
"compress/gzip"
"io"
"testing"

gcr "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/stretchr/testify/require"
)

type tarEntrySpec struct {
name string
content string
isDir bool
mode int64
}

// specLayer builds a gzipped tar layer from entry specs in order.
func specLayer(t *testing.T, entries []tarEntrySpec) gcr.Layer {
t.Helper()

var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
for _, entry := range entries {
if entry.isDir {
require.NoError(t, tw.WriteHeader(&tar.Header{Name: entry.name, Typeflag: tar.TypeDir, Mode: entry.mode}))
continue
}
require.NoError(t, tw.WriteHeader(&tar.Header{
Name: entry.name,
Typeflag: tar.TypeReg,
Mode: entry.mode,
Size: int64(len(entry.content)),
}))
_, err := tw.Write([]byte(entry.content))
require.NoError(t, err)
}
require.NoError(t, tw.Close())
require.NoError(t, gzw.Close())

data := buf.Bytes()
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
})
require.NoError(t, err)
return layer
}
Loading