diff --git a/lib/images/compose.go b/lib/images/compose.go new file mode 100644 index 000000000..dea6555a0 --- /dev/null +++ b/lib/images/compose.go @@ -0,0 +1,59 @@ +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 := c.applyLayerToDir(ctx, staging, desc); 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 +} + +func (c *ociClient) applyLayerToDir(ctx context.Context, dest string, desc layerDescriptor) error { + layerDir, err := os.MkdirTemp(filepath.Dir(dest), ".layer-*") + if err != nil { + return fmt.Errorf("create layer staging directory: %w", err) + } + defer os.RemoveAll(layerDir) + + if _, err := unpackCachedLayerContext(ctx, 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 +} diff --git a/lib/images/compose_test.go b/lib/images/compose_test.go new file mode 100644 index 000000000..3e0dea77d --- /dev/null +++ b/lib/images/compose_test.go @@ -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) +} diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 52d378829..aec1ec3d5 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" "crypto/sha256" "encoding/json" "errors" @@ -130,10 +131,38 @@ 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) { + return unpackCachedLayerContext(context.Background(), cacheDir, desc, dest) +} + +func unpackCachedLayerContext(ctx context.Context, 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 := unpackLayerBlobContext(ctx, blobPath, desc.MediaType, dest) + 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 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) { unlock := m.layerLocks.lock(desc.Digest) defer unlock() @@ -155,14 +184,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) @@ -173,12 +194,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) @@ -232,6 +250,10 @@ type unpackStats struct { // unpackLayerBlob extracts one compressed layer blob into dest, preserving // whiteout marker files and recording them. Paths are confined to dest. func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { + return unpackLayerBlobContext(context.Background(), blobPath, mediaType, dest) +} + +func unpackLayerBlobContext(ctx context.Context, blobPath, mediaType, dest string) (*unpackStats, error) { if err := os.MkdirAll(dest, 0755); err != nil { return nil, fmt.Errorf("create extraction root: %w", err) } @@ -246,6 +268,7 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { return nil, err } defer closer.Close() + reader = contextReader{ctx: ctx, reader: reader} hash := sha256.New() stats := &unpackStats{whiteouts: make([]whiteoutRecord, 0)} @@ -257,6 +280,9 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { hashedReader := io.TeeReader(limitedReader, hash) tr := tar.NewReader(hashedReader) for { + if err := ctx.Err(); err != nil { + return nil, err + } header, err := tr.Next() if err == io.EOF { break @@ -387,6 +413,18 @@ func decompressLayer(blob *os.File, mediaType string) (io.Reader, io.Closer, err } } +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} + type multiCloser []io.Closer func (c multiCloser) Close() error { @@ -620,6 +658,9 @@ func applyLayerTree(layerDir, targetDir string) (err error) { } } }() + if err := prepareLayerDirectories(layerDir, targetDir, originalModes); err != nil { + return err + } // Phase 1: apply whiteouts against what is already in the target. err = filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { @@ -707,9 +748,46 @@ func applyLayerTree(layerDir, targetDir string) (err error) { return fmt.Errorf("restore dir metadata %s: %w", dir.dst, err) } } + rootInfo, err := os.Lstat(layerDir) + if err != nil { + return fmt.Errorf("stat layer root: %w", err) + } + if err := copyEntryMetadata(layerDir, targetDir, rootInfo); err != nil { + return fmt.Errorf("restore layer root metadata: %w", err) + } return nil } +func prepareLayerDirectories(layerDir, targetDir string, originalModes map[string]fs.FileMode) error { + return filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == layerDir || !entry.IsDir() { + return nil + } + rel, err := filepath.Rel(layerDir, path) + if err != nil { + return err + } + target, err := safeJoin(targetDir, rel) + if err != nil { + return err + } + if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { + return err + } + if info, err := os.Lstat(target); err == nil && !info.IsDir() { + if err := removePath(target); err != nil { + return err + } + } else if err != nil && !os.IsNotExist(err) { + return err + } + return os.MkdirAll(target, 0755) + }) +} + func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { root = filepath.Clean(root) path = filepath.Clean(path) diff --git a/lib/images/oci.go b/lib/images/oci.go index 2a1a23223..ddc437037 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -283,9 +283,9 @@ 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. if err := result.measure("layer_unpack", func() error { - return c.unpackLayers(ctx, layoutTag, exportDir) + return c.composeRootfsContext(ctx, exportDir, layoutTag, bundle.Model) }); err != nil { return result, fmt.Errorf("unpack layers: %w", err) } diff --git a/lib/images/recovery_regression_test.go b/lib/images/recovery_regression_test.go index b979939a9..ad3398316 100644 --- a/lib/images/recovery_regression_test.go +++ b/lib/images/recovery_regression_test.go @@ -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 { diff --git a/lib/images/testlayers_test.go b/lib/images/testlayers_test.go new file mode 100644 index 000000000..d56cf1015 --- /dev/null +++ b/lib/images/testlayers_test.go @@ -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 +}