diff --git a/lib/images/compose.go b/lib/images/compose.go new file mode 100644 index 000000000..a44ba08b7 --- /dev/null +++ b/lib/images/compose.go @@ -0,0 +1,53 @@ +package images + +import ( + "context" + "fmt" + "log/slog" + "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. Any previous tree at dest is replaced: callers must not read dest +// concurrently, and a failure between the remove and the rename leaves dest +// absent. A crash can also strand .compose-* staging directories in dest's +// parent, the same way .unpack-* directories can strand under layer builds. +func (c *ociClient) composeRootfs(ctx context.Context, dest, layoutTag string, model *imageManifestModel) error { + if err := validateManifestModel(layoutTag, model); err != nil { + return fmt.Errorf("validate manifest model: %w", err) + } + parent := filepath.Dir(dest) + if err := os.MkdirAll(parent, 0755); err != nil { + return fmt.Errorf("create compose parent: %w", err) + } + staging, err := os.MkdirTemp(parent, ".compose-*") + if err != nil { + return fmt.Errorf("create compose directory: %w", err) + } + defer func() { + if err := removePath(staging); err != nil { + slog.Warn("failed to remove compose staging directory", "dir", staging, "error", err) + } + }() + + for i, desc := range model.Layers { + if _, err := unpackCachedLayer(ctx, c.cacheBlobDir(), desc, staging, composeOnDiskFormat()); err != nil { + return fmt.Errorf("apply layer %d: %w", i, err) + } + } + // The export directory must stay traversable by other readers; MkdirTemp + // creates it 0700. + if err := os.Chmod(staging, 0755); err != nil { + return fmt.Errorf("set compose directory mode: %w", err) + } + if err := removePath(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 +} diff --git a/lib/images/compose_test.go b/lib/images/compose_test.go new file mode 100644 index 000000000..5771183a9 --- /dev/null +++ b/lib/images/compose_test.go @@ -0,0 +1,211 @@ +package images + +import ( + "context" + "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") + // Pre-populate dest and weaken its mode so composition must replace the + // whole tree and restore the 0755 export-directory mode. + require.NoError(t, os.MkdirAll(filepath.Join(dest, "junk"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "junk", "stale.txt"), []byte("stale"), 0644)) + require.NoError(t, client.composeRootfs(context.Background(), dest, tag, model)) + + // Stale content is gone and the export directory mode is restored. + _, err := os.Lstat(filepath.Join(dest, "junk")) + require.True(t, os.IsNotExist(err), "composition must replace the previous tree") + info, err := os.Stat(dest) + require.NoError(t, err) + require.Equal(t, os.FileMode(0755), info.Mode().Perm()) + + // 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 + })) +} + +func TestComposeRootfsEmptyLayers(t *testing.T) { + p := paths.New(t.TempDir()) + client, err := newOCIClient(p.SystemOCICache()) + require.NoError(t, err) + model := &imageManifestModel{ + SchemaVersion: manifestModelSchemaVersion, + Digest: "sha256:" + strings.Repeat("ab", 32), + RootFSType: "layers", + Config: manifestConfigRef{Digest: "sha256:" + strings.Repeat("cd", 32)}, + Layers: make([]layerDescriptor, 0), + } + dest := filepath.Join(t.TempDir(), "rootfs") + require.NoError(t, client.composeRootfs(context.Background(), 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(context.Background(), 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, + RootFSType: "layers", + 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(context.Background(), 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) + + // Desynchronize the top layer's diff id from its content while keeping the + // model internally consistent, so validation passes and the mismatch is + // caught against the unpacked stream instead. + forged := "sha256:" + strings.Repeat("ff", 32) + model.Layers[1].DiffID = forged + model.Config.DiffIDs[1] = forged + + err := client.composeRootfs(context.Background(), 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 passes fsck. +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) + + dest := filepath.Join(t.TempDir(), "rootfs") + require.NoError(t, client.composeRootfs(context.Background(), dest, tag, model)) + + diskPath := filepath.Join(t.TempDir(), "rootfs.erofs") + size, err := ExportRootfs(dest, 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 d7b79fe19..e2faf8093 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -94,9 +94,8 @@ func layerArtifactRecordPath(p *paths.Paths, layerHex string) string { // layerMapOptions preserves tar ownership when running as root. Otherwise // umoci's rootless mode skips chown and stands in empty files for device nodes. -// Unlike unpackLayers in oci.go, which maps container root to the current -// user, this deliberately leaves ownership untouched as root: artifacts must -// keep the layer's on-disk ownership for later stacking. +// As root this deliberately leaves ownership untouched: artifacts must keep +// the layer's on-disk ownership for later stacking. func layerMapOptions() layer.MapOptions { return layer.MapOptions{Rootless: os.Geteuid() != 0} } @@ -109,6 +108,14 @@ func layerArtifactOnDiskFormat() layer.OnDiskFormat { return layer.OverlayfsRootfs{MapOptions: layerMapOptions()} } +// composeOnDiskFormat applies whiteouts against the tree being composed: +// deletions execute immediately on the destination instead of becoming +// overlayfs whiteout inodes, since the composed tree is mounted as a single +// lower filesystem rather than stacked. +func composeOnDiskFormat() layer.OnDiskFormat { + return layer.DirRootfs{MapOptions: layerMapOptions()} +} + // readLayerRecord loads the artifact record for a layer digest, if present. // A missing record returns (nil, nil): the layer simply was never // materialized. @@ -215,7 +222,7 @@ func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDe } }() - stats, err := unpackCachedLayer(ctx, m.paths, desc, unpackDir, layerArtifactOnDiskFormat()) + stats, err := unpackCachedLayer(ctx, m.paths.OCICacheBlobDir(), desc, unpackDir, layerArtifactOnDiskFormat()) if err != nil { return nil, err } @@ -274,11 +281,12 @@ func (r contextReader) Read(p []byte) (int, error) { return r.reader.Read(p) } -// unpackCachedLayer locates desc's blob in the shared OCI cache, unpacks it -// into dest, and verifies both the blob digest and the diff ID when the -// descriptor carries one. The caller must have validated desc.Digest. -func unpackCachedLayer(ctx context.Context, p *paths.Paths, desc layerDescriptor, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { - blobPath := p.OCICacheBlob(strings.TrimPrefix(desc.Digest, "sha256:")) +// unpackCachedLayer locates desc's blob under blobDir (the OCI layout's +// blobs/sha256 directory), unpacks it into dest, and verifies both the blob +// digest and the diff ID when the descriptor carries one. The caller must +// have validated desc.Digest. +func unpackCachedLayer(ctx context.Context, blobDir string, desc layerDescriptor, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { + blobPath := filepath.Join(blobDir, strings.TrimPrefix(desc.Digest, "sha256:")) if _, err := os.Stat(blobPath); err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest) diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 2ba416031..28e3cca7e 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -19,7 +19,6 @@ import ( "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/kernel/hypeman/lib/paths" - "github.com/opencontainers/umoci/oci/layer" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) @@ -31,12 +30,6 @@ import ( // later be stacked. const whiteoutPrefix = ".wh." -// composeOnDiskFormat applies whiteouts against the tree being composed. It -// belongs to the composition flow and moves to production with that change. -func composeOnDiskFormat() layer.OnDiskFormat { - return layer.DirRootfs{MapOptions: layerMapOptions()} -} - const testTarGzMediaType = "application/vnd.oci.image.layer.v1.tar+gzip" // writeLayerTestLayout writes img into the shared OCI cache of p tagged with diff --git a/lib/images/manifest_model.go b/lib/images/manifest_model.go index 2e4fc3e09..5fcaf7151 100644 --- a/lib/images/manifest_model.go +++ b/lib/images/manifest_model.go @@ -23,7 +23,7 @@ type imageManifestModel struct { MediaType string `json:"media_type,omitempty"` Platform string `json:"platform"` // os/arch[/variant] Config manifestConfigRef `json:"config"` - RootFSType string `json:"rootfs_type,omitempty"` + RootFSType string `json:"rootfs_type"` Layers []layerDescriptor `json:"layers"` // manifest order, base layer first } @@ -41,7 +41,7 @@ type layerDescriptor struct { Digest string `json:"digest"` // compressed blob digest, sha256:... Size int64 `json:"size"` // compressed bytes MediaType string `json:"media_type,omitempty"` - DiffID string `json:"diff_id,omitempty"` // uncompressed diff id from the image config + DiffID string `json:"diff_id"` // uncompressed diff id from the image config } // digestFromHex returns the full sha256 digest string for a bare hex value. @@ -76,7 +76,7 @@ func validateManifestModel(digestHex string, model *imageManifestModel) error { if model.Digest != digestFromHex(digestHex) { return fmt.Errorf("manifest model digest %q does not match %q", model.Digest, digestFromHex(digestHex)) } - if model.RootFSType != "" && model.RootFSType != "layers" { + if model.RootFSType != "layers" { return fmt.Errorf("unsupported manifest rootfs type: %q", model.RootFSType) } if err := validateManifestConfig(model); err != nil { diff --git a/lib/images/manifest_model_test.go b/lib/images/manifest_model_test.go index 1137e3912..9f757bfd8 100644 --- a/lib/images/manifest_model_test.go +++ b/lib/images/manifest_model_test.go @@ -1,11 +1,7 @@ package images import ( - "archive/tar" - "bytes" - "compress/gzip" "context" - "io" "os" "strings" "testing" @@ -15,7 +11,6 @@ import ( "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/mutate" - "github.com/google/go-containerregistry/pkg/v1/tarball" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/require" @@ -24,44 +19,21 @@ import ( // syntheticLayer builds a gzipped tar layer containing one file. func syntheticLayer(t *testing.T, name, content string) gcr.Layer { t.Helper() - - var buf bytes.Buffer - gzw := gzip.NewWriter(&buf) - tw := tar.NewWriter(gzw) - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: name, - Size: int64(len(content)), - Typeflag: tar.TypeReg, - Mode: 0644, - })) - _, err := tw.Write([]byte(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 + return specLayer(t, []tarEntrySpec{{name: name, content: content, mode: 0644}}) } -// writeSyntheticLayout writes img into a fresh OCI layout cache tagged with the -// image's own digest, mirroring pullToOCILayout. +// writeSyntheticLayout writes img into a temp-dir-backed paths root and +// returns a client for its cache plus the image's layout tag. func writeSyntheticLayout(t *testing.T, img gcr.Image) (*ociClient, string) { t.Helper() - client, err := newOCIClient(t.TempDir()) - require.NoError(t, err) + p := paths.New(t.TempDir()) + writeLayerTestLayout(t, p, img) - digest, err := img.Digest() + client, err := newOCIClient(p.SystemOCICache()) require.NoError(t, err) - layoutPath, err := layout.Write(client.cacheDir, empty.Index) + digest, err := img.Digest() require.NoError(t, err) - require.NoError(t, layoutPath.AppendImage(img, layout.WithAnnotations(map[string]string{ - "org.opencontainers.image.ref.name": digestToLayoutTag(digest.String()), - }))) return client, digestToLayoutTag(digest.String()) } @@ -130,6 +102,7 @@ func TestManifestModelWriteReadRoundtrip(t *testing.T) { model := &imageManifestModel{ SchemaVersion: manifestModelSchemaVersion, Digest: "sha256:" + digestHex, + RootFSType: "layers", Platform: "linux/amd64", Config: manifestConfigRef{ Digest: "sha256:" + strings.Repeat("c", 64), @@ -157,11 +130,27 @@ func TestReadManifestModelRejectsInvalidSchema(t *testing.T) { p := paths.New(t.TempDir()) digestHex := strings.Repeat("a", 64) require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0755)) - require.NoError(t, os.WriteFile(p.ImageContentManifestModel(digestHex), []byte(`{"schema_version":1,"digest":"sha256:`+digestHex+`"}`), 0644)) + require.NoError(t, os.WriteFile(p.ImageContentManifestModel(digestHex), + []byte(`{"schema_version":1,"digest":"sha256:`+digestHex+`","rootfs_type":"layers"}`), 0644)) _, err := readManifestModel(p, digestHex) require.ErrorContains(t, err, "config digest is empty") } +func TestValidateManifestModelRejectsNonLayeredRootFS(t *testing.T) { + model := &imageManifestModel{ + SchemaVersion: manifestModelSchemaVersion, + Digest: "sha256:" + strings.Repeat("ab", 32), + RootFSType: "", + Config: manifestConfigRef{Digest: "sha256:" + strings.Repeat("cd", 32)}, + } + err := validateManifestModel(model.Digest, model) + require.ErrorContains(t, err, "rootfs type") + + model.RootFSType = "flattened" + err = validateManifestModel(model.Digest, model) + require.ErrorContains(t, err, "rootfs type") +} + func TestReadManifestModelMissing(t *testing.T) { p := paths.New(t.TempDir()) model, err := readManifestModel(p, "deadbeef") diff --git a/lib/images/oci.go b/lib/images/oci.go index 88bd6de24..b50a3f659 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "time" @@ -13,13 +14,9 @@ import ( "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/remote" - digest "github.com/opencontainers/go-digest" - "github.com/opencontainers/image-spec/specs-go" v1 "github.com/opencontainers/image-spec/specs-go/v1" - rspec "github.com/opencontainers/runtime-spec/specs-go" "github.com/opencontainers/umoci/oci/cas/dir" "github.com/opencontainers/umoci/oci/casext" - "github.com/opencontainers/umoci/oci/layer" ) // ociClient handles OCI image operations without requiring Docker daemon @@ -77,6 +74,11 @@ func newOCIClient(cacheDir string) (*ociClient, error) { return &ociClient{cacheDir: cacheDir}, nil } +// cacheBlobDir returns the OCI layout's blob directory under the cache root. +func (c *ociClient) cacheBlobDir() string { + return filepath.Join(c.cacheDir, "blobs", "sha256") +} + // vmPlatform returns the target platform for VM images: a Linux guest on the // host architecture. Hypeman VMs are always Linux regardless of host OS. func vmPlatform() gcr.Platform { @@ -283,11 +285,11 @@ 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.composeRootfs(ctx, exportDir, layoutTag, bundle.Model) }); err != nil { - return result, fmt.Errorf("unpack layers: %w", err) + return result, fmt.Errorf("compose rootfs: %w", err) } return result, nil @@ -497,139 +499,6 @@ func manifestModelFromImage(layoutTag string, configFile *gcr.ConfigFile, manife return model, compressedBytes } -// unpackLayers unpacks all OCI layers to a target directory using umoci -// Uses go-containerregistry to get the manifest (handles both Docker v2 and OCI v1) -// then converts it to OCI v1 format for umoci's layer unpacker. -func (c *ociClient) unpackLayers(ctx context.Context, layoutTag, targetDir string) error { - // Open OCI layout using go-containerregistry (handles Docker v2 and OCI v1) - path, err := layout.FromPath(c.cacheDir) - if err != nil { - return fmt.Errorf("open oci layout: %w", err) - } - - // Get the image by annotation tag from the layout - img, err := imageByAnnotation(path, layoutTag) - if err != nil { - return fmt.Errorf("find image by tag %s: %w", layoutTag, err) - } - - // Get manifest from go-containerregistry - gcrManifest, err := img.Manifest() - if err != nil { - return fmt.Errorf("get manifest: %w", err) - } - - configFile, err := img.ConfigFile() - if err != nil { - return fmt.Errorf("get config file: %w", err) - } - if err := validateConfigFileForUnpack(layoutTag, gcrManifest, configFile); err != nil { - return err - } - - // Convert go-containerregistry manifest to OCI v1.Manifest for umoci - ociManifest := convertToOCIManifest(gcrManifest) - - // Open the shared OCI layout with umoci for layer unpacking - casEngine, err := dir.Open(c.cacheDir) - if err != nil { - return fmt.Errorf("open oci layout for unpacking: %w", err) - } - defer casEngine.Close() - - // Pre-create target directory (umoci needs it to exist) - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("create target dir: %w", err) - } - - // Unpack layers using umoci's layer package with rootless mode - // Map container UIDs to current user's UID (identity mapping) - uid := uint32(os.Getuid()) - gid := uint32(os.Getgid()) - - unpackOpts := &layer.UnpackOptions{ - OnDiskFormat: layer.DirRootfs{ - MapOptions: layer.MapOptions{ - Rootless: true, // Don't fail on chown errors - UIDMappings: []rspec.LinuxIDMapping{ - {HostID: uid, ContainerID: 0, Size: 1}, // Map container root to current user - }, - GIDMappings: []rspec.LinuxIDMapping{ - {HostID: gid, ContainerID: 0, Size: 1}, // Map container root group to current user group - }, - }, - }, - } - - err = layer.UnpackRootfs(ctx, casEngine, targetDir, ociManifest, unpackOpts) - if err != nil { - return fmt.Errorf("unpack rootfs: %w", err) - } - - return nil -} - -// validateConfigFileForUnpack rejects malformed image configs before calling -// umoci. In particular, we verify that the config blob resolves to a real OCI -// image config, that it declares a layered rootfs, and that rootfs.diff_ids has -// one entry per manifest layer so umoci won't index past the end of the slice. -func validateConfigFileForUnpack(layoutTag string, manifest *gcr.Manifest, configFile *gcr.ConfigFile) error { - if convertToOCIMediaType(string(manifest.Config.MediaType)) != v1.MediaTypeImageConfig { - return fmt.Errorf( - "unpack rootfs: config blob is not correct mediatype %s: %s", - v1.MediaTypeImageConfig, - manifest.Config.MediaType, - ) - } - if configFile.RootFS.Type != "layers" { - return fmt.Errorf("unpack rootfs: config: unsupported rootfs.type: %s", configFile.RootFS.Type) - } - if len(configFile.RootFS.DiffIDs) != len(manifest.Layers) { - return fmt.Errorf( - "unpack rootfs: config rootfs.diff_ids has %d entries but manifest has %d layers for %s", - len(configFile.RootFS.DiffIDs), - len(manifest.Layers), - layoutTag, - ) - } - return nil -} - -// convertToOCIManifest converts a go-containerregistry manifest to OCI v1.Manifest -// This allows us to use go-containerregistry (which handles both Docker v2 and OCI v1) -// for manifest parsing, while still using umoci for layer unpacking. -// Docker v2 mediatypes are converted to OCI equivalents since umoci expects OCI format. -func convertToOCIManifest(gcrManifest *gcr.Manifest) v1.Manifest { - // Convert config descriptor with mediatype conversion - configDesc := v1.Descriptor{ - MediaType: convertToOCIMediaType(string(gcrManifest.Config.MediaType)), - Digest: gcrDigestToOCI(gcrManifest.Config.Digest), - Size: gcrManifest.Config.Size, - Annotations: gcrManifest.Config.Annotations, - } - - // Convert layer descriptors with mediatype conversion - layers := make([]v1.Descriptor, len(gcrManifest.Layers)) - for i, layer := range gcrManifest.Layers { - layers[i] = v1.Descriptor{ - MediaType: convertToOCIMediaType(string(layer.MediaType)), - Digest: gcrDigestToOCI(layer.Digest), - Size: layer.Size, - Annotations: layer.Annotations, - } - } - - return v1.Manifest{ - Versioned: specs.Versioned{ - SchemaVersion: int(gcrManifest.SchemaVersion), - }, - MediaType: convertToOCIMediaType(string(gcrManifest.MediaType)), - Config: configDesc, - Layers: layers, - Annotations: gcrManifest.Annotations, - } -} - // convertToOCIMediaType converts Docker v2 media types to OCI equivalents. // Images from Docker Hub often use Docker-specific mediatypes, but umoci // requires OCI-standard mediatypes for layer unpacking. @@ -649,11 +518,6 @@ func convertToOCIMediaType(mediaType string) string { } } -// gcrDigestToOCI converts a go-containerregistry digest to OCI digest -func gcrDigestToOCI(d gcr.Hash) digest.Digest { - return digest.NewDigestFromEncoded(digest.Algorithm(d.Algorithm), d.Hex) -} - type containerMetadata struct { OS string Architecture string diff --git a/lib/images/oci_test.go b/lib/images/oci_test.go index 2cb765cc5..56510c314 100644 --- a/lib/images/oci_test.go +++ b/lib/images/oci_test.go @@ -42,17 +42,14 @@ const testImageKernelVersion = "ch-6.12.8-kernel-1.6-202603301" // cache with image-manifest=true const buildKitCacheConfigMediaType = "application/vnd.buildkit.cacheconfig.v0" -// TestUnpackLayersFailsOnBuildKitCacheMediatype verifies that hypeman's image -// unpacker fails when encountering BuildKit cache images. This reproduces the -// production issue where global cache images exported by BuildKit cannot be -// pre-pulled by hypeman because they use a non-standard config mediatype. +// TestComposeRootfsFailsOnBuildKitCacheMediatype verifies that rootfs +// composition fails when encountering BuildKit cache images. This reproduces +// the production issue where global cache images exported by BuildKit cannot +// be pre-pulled by hypeman because they use a non-standard config mediatype. // -// The error occurs because: -// 1. BuildKit exports cache with --export-cache type=registry,image-manifest=true -// 2. The exported manifest uses "application/vnd.buildkit.cacheconfig.v0" as config mediatype -// 3. hypeman's unpackLayers expects "application/vnd.oci.image.config.v1+json" -// 4. umoci.UnpackRootfs fails with "config blob is not correct mediatype" -func TestUnpackLayersFailsOnBuildKitCacheMediatype(t *testing.T) { +// The fixture's cacheconfig blob declares no layered rootfs, so manifest +// model validation rejects the image before any blob is read. +func TestComposeRootfsFailsOnBuildKitCacheMediatype(t *testing.T) { // Create a temp directory for the OCI layout cacheDir := t.TempDir() @@ -60,24 +57,90 @@ func TestUnpackLayersFailsOnBuildKitCacheMediatype(t *testing.T) { err := createBuildKitCacheLayout(cacheDir, "test-cache") require.NoError(t, err, "failed to create mock BuildKit cache layout") - // Create OCI client and try to unpack + // Create OCI client and extract the bundle client, err := newOCIClient(cacheDir) require.NoError(t, err) + bundle, err := client.extractOCIImageBundle("test-cache") + require.NoError(t, err) - targetDir := t.TempDir() - err = client.unpackLayers(context.Background(), "test-cache", targetDir) + err = client.composeRootfs(context.Background(), filepath.Join(t.TempDir(), "rootfs"), "test-cache", bundle.Model) - // This should fail with a mediatype error - require.Error(t, err, "unpackLayers should fail on BuildKit cache mediatype") - assert.Contains(t, err.Error(), "config", "error should mention config") + // The cacheconfig config blob declares no layered rootfs, so manifest model + // validation rejects it before any blob is read. + require.Error(t, err, "compose should fail on BuildKit cache mediatype") + assert.Contains(t, err.Error(), "rootfs type", "error should be the rootfs type rejection") t.Logf("Got expected error: %v", err) } -// TestExtractMetadataSucceedsOnBuildKitCache verifies that extractOCIMetadata +// TestComposeRootfsFailsOnBuildKitCacheConfigMediatype pins the config +// mediatype rejection specifically: with a layered rootfs declared, the +// cacheconfig config mediatype itself is what fails validation. +func TestComposeRootfsFailsOnBuildKitCacheConfigMediatype(t *testing.T) { + cacheDir := t.TempDir() + blobsDir := filepath.Join(cacheDir, "blobs", "sha256") + require.NoError(t, os.MkdirAll(blobsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0644)) + + layerContent := []byte{ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, // gzip header + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // empty tar + } + layerDigest := sha256Hash(layerContent) + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, layerDigest), layerContent, 0644)) + + // BuildKit cacheconfig-shaped config that nonetheless declares a layered + // rootfs, so the config mediatype check is the one that fires. + configJSON := []byte(`{"rootfs":{"type":"layers","diff_ids":["sha256:` + layerDigest + `"]}}`) + configDigest := sha256Hash(configJSON) + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, configDigest), configJSON, 0644)) + + manifest := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": map[string]interface{}{ + "mediaType": buildKitCacheConfigMediaType, + "digest": "sha256:" + configDigest, + "size": len(configJSON), + }, + "layers": []map[string]interface{}{{ + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:" + layerDigest, + "size": len(layerContent), + }}, + } + manifestBytes, err := json.Marshal(manifest) + require.NoError(t, err) + manifestDigest := sha256Hash(manifestBytes) + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, manifestDigest), manifestBytes, 0644)) + + index := map[string]interface{}{ + "schemaVersion": 2, + "manifests": []map[string]interface{}{{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:" + manifestDigest, + "size": len(manifestBytes), + "annotations": map[string]string{"org.opencontainers.image.ref.name": "test-cache"}, + }}, + } + indexBytes, err := json.Marshal(index) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "index.json"), indexBytes, 0644)) + + client, err := newOCIClient(cacheDir) + require.NoError(t, err) + bundle, err := client.extractOCIImageBundle("test-cache") + require.NoError(t, err) + + err = client.composeRootfs(context.Background(), filepath.Join(t.TempDir(), "rootfs"), "test-cache", bundle.Model) + require.Error(t, err) + assert.Contains(t, err.Error(), "config media type", "error should be the config mediatype rejection") +} + +// TestExtractMetadataSucceedsOnBuildKitCache verifies that extractOCIImageBundle // does NOT fail on BuildKit cache images - it's go-containerregistry which is -// lenient about mediatypes. The failure only happens during unpackLayers when -// umoci tries to unpack the rootfs. +// lenient about mediatypes. The failure only happens during composition when +// the manifest model is validated. func TestExtractMetadataSucceedsOnBuildKitCache(t *testing.T) { cacheDir := t.TempDir() @@ -88,7 +151,6 @@ func TestExtractMetadataSucceedsOnBuildKitCache(t *testing.T) { require.NoError(t, err) // This succeeds because go-containerregistry doesn't validate config mediatype - // The failure only happens in unpackLayers when umoci validates the config bundle, err := client.extractOCIImageBundle("test-cache") require.NoError(t, err, "extractOCIImageBundle succeeds - go-containerregistry is lenient") @@ -292,7 +354,7 @@ func createTestDockerImage(t *testing.T) v1.Image { // TestDockerSaveTarballToOCILayoutRoundtrip tests the exact pipeline used by // buildBuilderFromDockerfile: docker save tarball → load via go-containerregistry -// → write to OCI layout cache → verify existsInLayout + extractMetadata + unpackLayers. +// → write to OCI layout cache → verify existsInLayout + extractMetadata + composeRootfs. // // This simulates: // 1. docker build → docker save (we use go-containerregistry to create the tarball) @@ -300,7 +362,7 @@ func createTestDockerImage(t *testing.T) v1.Image { // 3. layout.AppendImage with digest annotation (write to OCI cache) // 4. existsInLayout (cache hit detection) // 5. extractOCIMetadata (read config from cache) -// 6. unpackLayers (unpack rootfs from cache) +// 6. composeRootfs (compose rootfs from cache blobs) func TestDockerSaveTarballToOCILayoutRoundtrip(t *testing.T) { // Step 1: Create a synthetic Docker image (simulates docker build output) img := createTestDockerImage(t) @@ -347,10 +409,9 @@ func TestDockerSaveTarballToOCILayoutRoundtrip(t *testing.T) { assert.Equal(t, testImageKernelVersion, meta.Labels["io.kernel.kernel-version"]) assert.Equal(t, "6.12.8+", meta.Labels["io.kernel.kernel-release"]) - // Step 7: Verify unpackLayers produces correct rootfs - // umoci's UnpackRootfs extracts directly into the target directory + // Step 7: Verify composeRootfs produces correct rootfs unpackDir := filepath.Join(t.TempDir(), "unpack") - err = client.unpackLayers(context.Background(), layoutTag, unpackDir) + err = client.composeRootfs(context.Background(), unpackDir, layoutTag, bundle.Model) require.NoError(t, err) // Verify expected files exist in unpacked rootfs @@ -369,7 +430,7 @@ func TestDockerSaveTarballToOCILayoutRoundtrip(t *testing.T) { require.NoError(t, err, "/app directory should exist") assert.True(t, stat.IsDir()) - t.Log("Full roundtrip verified: docker save tarball → OCI layout → existsInLayout → extractMetadata → unpackLayers") + t.Log("Full roundtrip verified: docker save tarball → OCI layout → existsInLayout → extractMetadata → composeRootfs") } // TestDockerSaveToOCILayoutCacheHit verifies that pullAndExport correctly diff --git a/lib/images/recovery_regression_test.go b/lib/images/recovery_regression_test.go index b979939a9..489925dee 100644 --- a/lib/images/recovery_regression_test.go +++ b/lib/images/recovery_regression_test.go @@ -21,19 +21,22 @@ const ( recoveryFixtureDigestHex = "073e2a02f0df492def76940a909b6b79b896fc8907cceeb03452b250697d98fa" ) -func TestUnpackLayersCapturedFixtureReturnsErrorInsteadOfPanicking(t *testing.T) { +func TestComposeRootfsCapturedFixtureReturnsErrorInsteadOfPanicking(t *testing.T) { dataDir := copyRecoveryFixture(t) client, err := newOCIClient(filepath.Join(dataDir, "system", "oci-cache")) require.NoError(t, err) - var unpackErr error + bundle, err := client.extractOCIImageBundle(recoveryFixtureTag) + require.NoError(t, err) + + var composeErr error require.NotPanics(t, func() { - unpackErr = client.unpackLayers(context.Background(), recoveryFixtureTag, filepath.Join(t.TempDir(), "rootfs")) + composeErr = client.composeRootfs(context.Background(), filepath.Join(t.TempDir(), "rootfs"), recoveryFixtureTag, bundle.Model) }) - require.Error(t, unpackErr) - assert.Contains(t, unpackErr.Error(), "config rootfs.diff_ids has 0 entries but manifest has 1 layers") + require.Error(t, composeErr) + assert.Contains(t, composeErr.Error(), "manifest model has 0 diff ids for 1 layers") } func TestRecoverInterruptedBuildsCapturedFixtureMarksBuildFailed(t *testing.T) { @@ -65,7 +68,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 +}