From 05f0810f39ab673edd208d59061626bbe995cd45 Mon Sep 17 00:00:00 2001 From: chris lee Date: Sun, 11 Oct 2026 17:49:10 -0400 Subject: [PATCH] macOS guests 2/6: OCI machine-image integration Materializes darwin/arm64 machine images from OCI artifacts with config validation before unpack, cached and pulled platform checks, private staging and rollback. Linux image pulls are unchanged. --- docs/macos-experimental.md | 43 ++- lib/diskutilization/diskutilization.go | 2 +- lib/diskutilization/diskutilization_test.go | 6 +- lib/images/finalization_rollback_test.go | 48 +++ lib/images/finalize.go | 115 +++++++ lib/images/macos_import_darwin_test.go | 7 +- lib/images/macos_machine.go | 209 ++++++++++++ lib/images/macos_machine_test.go | 337 ++++++++++++++++++++ lib/images/macos_stage_test.go | 28 ++ lib/images/macos_unpack.go | 174 ++++++++++ lib/images/macos_unpack_test.go | 233 ++++++++++++++ lib/images/manager.go | 128 ++------ lib/images/manager_test.go | 2 +- lib/images/oci.go | 16 +- lib/images/platform.go | 6 +- lib/images/storage.go | 19 +- lib/images/tag.go | 4 +- 17 files changed, 1252 insertions(+), 125 deletions(-) create mode 100644 lib/images/finalization_rollback_test.go create mode 100644 lib/images/finalize.go create mode 100644 lib/images/macos_machine.go create mode 100644 lib/images/macos_machine_test.go create mode 100644 lib/images/macos_stage_test.go create mode 100644 lib/images/macos_unpack.go create mode 100644 lib/images/macos_unpack_test.go diff --git a/docs/macos-experimental.md b/docs/macos-experimental.md index c054536af..630993ff9 100644 --- a/docs/macos-experimental.md +++ b/docs/macos-experimental.md @@ -20,11 +20,44 @@ go run -tags containers_image_openpgp ./cmd/import-macos \ --name localhost/macos:spike ``` -The data directory needs APFS clonefile support, and a path short enough that -each guest's sockets fit the 103-byte macOS limit; a boot that would exceed it is -refused. Images are `darwin/arm64`, not -OCI macOS containers. Registry pulls, manifest resolution and tagging/promotion -of macOS images are unsupported. Do not mutate the imported image files. +The local importer needs APFS clonefile support. The data directory path must be +short enough that each guest's sockets fit the 103-byte macOS limit; a boot that +would exceed it is refused. Images are `darwin/arm64` machine images, not OCI +macOS containers. Registry pulls also support experimental complete-machine +bundles described below. Additional tags within the same +repository are supported; cross-repository promotion remains unsupported. +Do not mutate the imported image files. + +## Experimental OCI bundles + +The normal image API accepts a `darwin/arm64` OCI manifest whose configuration +labels describe a complete installed machine: + +| Label | Value | +|---|---| +| `io.hypeman.machine-image.version` | `1` | +| `io.hypeman.machine-image.kind` | `macos-image` | +| `io.hypeman.machine-image.disk-format` | `raw` | +| `io.hypeman.machine-image.disk-path` | Relative installed boot disk path | +| `io.hypeman.machine-image.aux-path` | Relative auxiliary storage path | +| `io.hypeman.machine-image.platform-path` | Relative macOS platform configuration JSON path | + +All three files must be distinct, nonempty regular files inside the unpacked +image. Platform configuration uses the local bundle's `MacOSImage` schema and +is limited to 64 KiB. Absolute paths, traversal and symlink escapes are rejected. +The installed disk is materialized directly rather than converted to a Linux +root filesystem. Machine layers are unpacked sparsely (zero chunks become holes), +accept only regular files (not GNU sparse tar entries) and directories, and are +checked against their diff IDs, so a mostly empty boot disk occupies only its +data. A bundle's layers may decompress to at most 1 TiB in total. The boot disk +is stored as `rootfs.raw`, as for a local import; machine images pulled before +that naming must be deleted and pulled again. The matching auxiliary storage and platform +identity are retained. Do not package live mutable guest storage. + +This schema is experimental pending reconciliation with other machine-image +platforms. Complete bundles only: no base/delta chain, identity rebinding, or +concurrent-template guarantee. Ordinary OCI transport does not imply sparse +distribution efficiency, boot portability, or container execution semantics. ## API diff --git a/lib/diskutilization/diskutilization.go b/lib/diskutilization/diskutilization.go index 27fef3c81..a75783a0b 100644 --- a/lib/diskutilization/diskutilization.go +++ b/lib/diskutilization/diskutilization.go @@ -56,7 +56,7 @@ func Collect(p *paths.Paths) (Breakdown, error) { return false } name := entry.Name() - return name == "rootfs.erofs" || name == "rootfs.ext4" + return name == "rootfs.erofs" || name == "rootfs.ext4" || name == "rootfs.raw" }) if err != nil { return Breakdown{}, err diff --git a/lib/diskutilization/diskutilization_test.go b/lib/diskutilization/diskutilization_test.go index 50a787017..e19db8d6f 100644 --- a/lib/diskutilization/diskutilization_test.go +++ b/lib/diskutilization/diskutilization_test.go @@ -22,6 +22,10 @@ func TestCollect_UsesAllocatedBytesAndClassifiesSnapshots(t *testing.T) { require.NoError(t, createSparseTestFile(imagePath, 32*1024*1024, []sparseWrite{ {offset: 0, data: bytes.Repeat([]byte("i"), 4096)}, })) + machineImagePath := filepath.Join(p.ImagesDir(), "content", "def", "rootfs.raw") + require.NoError(t, createSparseTestFile(machineImagePath, 32*1024*1024, []sparseWrite{ + {offset: 0, data: bytes.Repeat([]byte("m"), 4096)}, + })) ociBlobPath := filepath.Join(p.SystemOCICache(), "blobs", "sha256", "blob") require.NoError(t, createSparseTestFile(ociBlobPath, 16*1024, []sparseWrite{ @@ -74,7 +78,7 @@ func TestCollect_UsesAllocatedBytesAndClassifiesSnapshots(t *testing.T) { utilization, err := Collect(p) require.NoError(t, err) - require.Equal(t, allocatedBytesForPath(imagePath), utilization.Images) + require.Equal(t, allocatedBytesForPath(imagePath)+allocatedBytesForPath(machineImagePath), utilization.Images) require.Equal(t, allocatedBytesForPath(volumePath), utilization.Volumes) require.Equal(t, allocatedBytesForPath(rootfsOverlayPath), utilization.RootfsOverlays) require.Equal(t, allocatedBytesForPath(volumeOverlayPath), utilization.VolumeOverlays) diff --git a/lib/images/finalization_rollback_test.go b/lib/images/finalization_rollback_test.go new file mode 100644 index 000000000..0a5efbbf8 --- /dev/null +++ b/lib/images/finalization_rollback_test.go @@ -0,0 +1,48 @@ +package images + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestFinalizationRollbackPreservesUninstalledManifest(t *testing.T) { + for _, machine := range []bool{false, true} { + name := "rootfs" + if machine { + name = "macos machine" + } + t.Run(name, func(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + ref, err := ParseNormalizedRef("localhost/qa@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + require.NoError(t, err) + resolved := NewResolvedRef(ref, ref.Digest()) + require.NoError(t, writeMetadata(p, ref.Repository(), ref.DigestHex(), &imageMetadata{BuildID: "qa-build", Status: StatusPending})) + layout := resolveImageLayout(p, ref.Repository(), ref.DigestHex()) + modelPath := manifestModelPath(p, layout, ref.DigestHex()) + require.NoError(t, os.MkdirAll(filepath.Dir(modelPath), 0700)) + require.NoError(t, os.WriteFile(modelPath, []byte("preexisting-manifest"), 0600)) + staged := stagedImageFiles{disk: filepath.Join(layout.dir, "qa-staged-disk"), sizeBytes: 14} + require.NoError(t, os.WriteFile(staged.disk, []byte("synthetic-disk"), 0600)) + config := &containerMetadata{OS: "linux", Architecture: "arm64"} + if machine { + staged.macos = &stagedMacOS{aux: filepath.Join(layout.dir, "qa-staged-aux"), platform: &MacOSImage{}} + require.NoError(t, os.WriteFile(staged.macos.aux, []byte("synthetic-aux"), 0600)) + config = &containerMetadata{OS: "darwin", Architecture: "arm64"} + } + + // The empty manifest model fails validation after the disk (and aux) are installed. + err = m.finalizeImage(resolved, &pullResult{Metadata: config, Manifest: &imageManifestModel{}}, "qa-build", staged) + require.ErrorContains(t, err, "write manifest model") + data, err := os.ReadFile(modelPath) + require.NoError(t, err, "rollback must not remove a manifest that this attempt did not install") + require.Equal(t, "preexisting-manifest", string(data)) + require.NoFileExists(t, staged.finalDiskPath(layout), "the installed disk is rolled back") + require.NoFileExists(t, filepath.Join(layout.dir, "aux.img"), "installed auxiliary storage is rolled back") + }) + } +} diff --git a/lib/images/finalize.go b/lib/images/finalize.go new file mode 100644 index 000000000..c16396702 --- /dev/null +++ b/lib/images/finalize.go @@ -0,0 +1,115 @@ +package images + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/kernel/hypeman/lib/paths" +) + +// stagedImageFiles are the build outputs written before finalization takes createMu. +// Finalization only renames them into place and commits metadata. +type stagedImageFiles struct { + disk string // staged disk, beside its final path + sizeBytes int64 // logical size of the disk, recorded for accounting + macos *stagedMacOS // nil for rootfs images +} + +// remove deletes staged files that finalization did not install. +func (s stagedImageFiles) remove() { + os.Remove(s.disk) + if s.macos != nil { + os.Remove(s.macos.aux) + } +} + +func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, buildID string, staged stagedImageFiles) error { + m.createMu.Lock() + defer m.createMu.Unlock() + + layout := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()) + + // Read current metadata to preserve request info and reject stale builds. + meta, err := readMetadataAt(layout) + if err != nil || meta.BuildID != buildID { + return errStaleBuild + } + + // The pulled image config is the source of truth for the platform. + var requestedPlatform string + if meta.Request != nil { + requestedPlatform = meta.Request.Platform + } + actualPlatform, err := resolveManifestPlatform(result.Metadata, requestedPlatform) + if err != nil { + return err + } + + // Files installed before the metadata commit. Finalization failure removes them. + installed, err := installMacOSMachine(layout, staged, meta, actualPlatform) + if err != nil { + return rollbackFinalization(installed, err) + } + diskPath := staged.finalDiskPath(layout) + if err := installAtomically(diskPath, func(path string) error { + return os.Rename(staged.disk, path) + }); err != nil { + return rollbackFinalization(installed, fmt.Errorf("install image disk: %w", err)) + } + installed = append(installed, diskPath) + + // Persist the manifest content model beside the shared content so later + // stages can recompose the image from per-layer artifacts and GC can tell + // which OCI blobs are still referenced. + if result.Manifest != nil { + model := *result.Manifest + model.Platform = actualPlatform.String() + modelPath := manifestModelPath(m.paths, layout, ref.DigestHex()) + if err := writeManifestModelAt(modelPath, ref.DigestHex(), &model); err != nil { + return rollbackFinalization(installed, fmt.Errorf("write manifest model: %w", err)) + } + installed = append(installed, modelPath) + } + + meta.Status = StatusReady + meta.Error = nil + meta.Platform = actualPlatform.String() + meta.SizeBytes = staged.sizeBytes + meta.Entrypoint = result.Metadata.Entrypoint + meta.Cmd = result.Metadata.Cmd + meta.Env = result.Metadata.Env + meta.Labels = result.Metadata.Labels + meta.WorkingDir = result.Metadata.WorkingDir + + if err := writeMetadataFile(layout.metadata, meta); err != nil { + return rollbackFinalization(installed, fmt.Errorf("write final metadata: %w", err)) + } + + m.notifyReady(ref.DigestHex(), StatusReady, nil) + if !m.claimRequestedTags(ref, meta) { + m.cleanupUnclaimedImage(ref) + } + m.layers.refreshDiskUsageTotals() + return nil +} + +func manifestModelPath(p *paths.Paths, layout imageLayout, digestHex string) string { + if layout.content { + return p.ImageContentManifestModel(digestHex) + } + return filepath.Join(layout.dir, "manifest.json") +} + +// rollbackFinalization removes the files finalization installed before it failed. +func rollbackFinalization(installed []string, cause error) error { + var rollbackErr error + for _, path := range installed { + rollbackErr = errors.Join(rollbackErr, os.Remove(path)) + } + if rollbackErr != nil { + return errors.Join(cause, fmt.Errorf("rollback finalization: %w", rollbackErr)) + } + return cause +} diff --git a/lib/images/macos_import_darwin_test.go b/lib/images/macos_import_darwin_test.go index 8518fbab2..f8cb4b660 100644 --- a/lib/images/macos_import_darwin_test.go +++ b/lib/images/macos_import_darwin_test.go @@ -40,12 +40,13 @@ func TestMacOSOfflineImport(t *testing.T) { _, err = ImportMacOSImage(ctx, p, "localhost/macos:cancelled", source) require.Error(t, err) } -func TestMacOSPlatformLocalOnly(t *testing.T) { +func TestMacOSPlatform(t *testing.T) { p, err := ParsePlatform("darwin/arm64") require.NoError(t, err) require.Equal(t, "darwin", p.OS) _, err = ParsePlatform("darwin/amd64") require.Error(t, err) - _, err = resolveManifestPlatform(&containerMetadata{OS: "darwin", Architecture: "arm64"}, "") - require.ErrorIs(t, err, ErrInvalidPlatform) + manifest, err := resolveManifestPlatform(&containerMetadata{OS: "darwin", Architecture: "arm64"}, "") + require.NoError(t, err) + require.Equal(t, "darwin/arm64", manifest.String()) } diff --git a/lib/images/macos_machine.go b/lib/images/macos_machine.go new file mode 100644 index 000000000..8ce357019 --- /dev/null +++ b/lib/images/macos_machine.go @@ -0,0 +1,209 @@ +package images + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/paths" +) + +const ( + MacOSMachineVersionLabel = "io.hypeman.machine-image.version" + MacOSMachineKindLabel = "io.hypeman.machine-image.kind" + MacOSMachineDiskLabel = "io.hypeman.machine-image.disk-path" + MacOSMachineFormatLabel = "io.hypeman.machine-image.disk-format" + MacOSMachineAuxLabel = "io.hypeman.machine-image.aux-path" + MacOSMachinePlatformLabel = "io.hypeman.machine-image.platform-path" +) + +// stagedMacOS is the part of a staged macOS machine image beyond its boot disk. +type stagedMacOS struct { + aux string // staged auxiliary storage, beside its final path + platform *MacOSImage +} + +// finalDiskPath is where finalization installs the staged disk: the layout's rootfs +// path, or the raw machine disk beside it. +func (s stagedImageFiles) finalDiskPath(layout imageLayout) string { + if s.macos != nil { + return filepath.Join(layout.dir, machineDiskFile) + } + return layout.disk +} + +// installMacOSMachine installs a macOS machine image's auxiliary storage and records +// its platform on meta. It returns the paths installed, for rollback. Rootfs images +// install nothing here. +func installMacOSMachine(layout imageLayout, staged stagedImageFiles, meta *imageMetadata, actualPlatform Platform) ([]string, error) { + if actualPlatform.OS == "darwin" && staged.macos == nil { + return nil, fmt.Errorf("macOS image requires a validated machine bundle") + } + if staged.macos == nil { + return nil, nil + } + auxPath := filepath.Join(layout.dir, "aux.img") + if err := os.Rename(staged.macos.aux, auxPath); err != nil { + return nil, err + } + meta.MacOS = staged.macos.platform + return []string{auxPath}, nil +} + +// checkCachedMachinePlatform refuses to reuse a record whose platform does not match +// an explicit request when either is darwin, so it answers the same way an uncached +// pull would. A ready record knows its platform; an in-flight pull knows only the +// platform it was requested as. Failed records are pulled again, so they pass. +func checkCachedMachinePlatform(p *paths.Paths, ref *ResolvedRef, requested Platform) error { + meta, err := readMetadata(p, ref.Repository(), ref.DigestHex()) + if err != nil || meta.Status == StatusFailed { + return nil + } + recorded := meta.Platform + if meta.Status != StatusReady { + recorded = "" + if meta.Request != nil { + recorded = meta.Request.Platform + } + } + if recorded == "" { + return nil + } + actual, err := ParsePlatform(recorded) + if err == nil && requested.OS != "darwin" && actual.OS != "darwin" { + return nil + } + if err != nil || !requested.Matches(actual) { + return fmt.Errorf("%w: requested %s but cached manifest is %s", ErrInvalidPlatform, requested, recorded) + } + return nil +} + +// stageMacOSOutputs stages a macOS machine image's raw boot disk and auxiliary +// storage in layoutDir, outside the manager lock. For other images it stages nothing +// and returns diskTemp for the rootfs export. +func (m *manager) stageMacOSOutputs(ctx context.Context, digest, root string, meta *containerMetadata, layoutDir, diskTemp, buildID string) (stagedImageFiles, error) { + staged := stagedImageFiles{disk: diskTemp} + if !isMacOSMachine(meta) { + return staged, nil + } + start := time.Now() + staged.disk = filepath.Join(layoutDir, machineDiskFile+".tmp-"+buildID) + err := stageMacOSMachine(&staged, root, meta, filepath.Join(layoutDir, "aux.img.tmp-"+buildID)) + m.recordImageBuildPhase(ctx, digest, "filesystem_export", time.Since(start), phaseStatus(err), "not_applicable") + return staged, err +} + +// readMacOSMachine reads the unpacked bundle that validated labels describe. +func readMacOSMachine(root string, meta *containerMetadata) (*macOSMachinePayload, error) { + labels := meta.Labels + return readMacOSMachineBundle(root, labels[MacOSMachineDiskLabel], labels[MacOSMachineAuxLabel], labels[MacOSMachinePlatformLabel], false) +} + +// stageMacOSMachine copies an unpacked bundle's files to the staging paths. The +// recorded size is the boot disk's logical size, as for a local import. +func stageMacOSMachine(staged *stagedImageFiles, root string, meta *containerMetadata, auxTemp string) error { + payload, err := readMacOSMachine(root, meta) + if err != nil { + return err + } + if staged.sizeBytes, err = stageMachineFile(payload.Disk, staged.disk); err != nil { + return fmt.Errorf("stage boot disk: %w", err) + } + staged.macos = &stagedMacOS{aux: auxTemp, platform: payload.Platform} + if _, err := stageMachineFile(payload.Aux, auxTemp); err != nil { + return fmt.Errorf("stage auxiliary storage: %w", err) + } + return nil +} + +func stageMachineFile(src, dst string) (int64, error) { + // Copy helpers create the destination with the registry-supplied source mode, + // and a multi-GB copy would expose contents before any later chmod. The copy + // happens in installAtomically's fresh 0700 directory and is made 0600 before + // it appears at the canonical staging path. + err := installAtomically(dst, func(path string) error { + if err := forkvm.CopyRegularFile(src, path); err != nil { + return err + } + return os.Chmod(path, 0600) + }) + if err != nil { + return 0, err + } + info, err := os.Stat(dst) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// macOSMachineHost is true where macOS machine images can boot. Other hosts +// refuse to pull them. Tests override it. +var macOSMachineHost = runtime.GOOS == "darwin" + +// checkMachinePlatformHost refuses a darwin image on a host that cannot boot it. +func checkMachinePlatformHost(platform Platform) error { + if platform.OS == "darwin" && !macOSMachineHost { + return fmt.Errorf("%w: macOS machine images can only be pulled on a macOS host", ErrInvalidPlatform) + } + return nil +} + +// refuseMachineOnOtherHost refuses a darwin image before its layers are downloaded, +// on a host that cannot boot it. The registry client caches the config it fetches +// here for the layout write that follows. +func refuseMachineOnOtherHost(img gcr.Image) error { + if macOSMachineHost { + return nil + } + config, err := img.ConfigFile() + if err != nil { + return fmt.Errorf("fetch image config: %w", ClassifyRegistryError(err)) + } + return checkMachinePlatformHost(Platform{OS: config.OS, Architecture: config.Architecture, Variant: config.Variant}.Normalize()) +} + +// isMacOSMachine reports whether an image config is darwin. It normalizes as +// resolveManifestPlatform does, so aliases such as aarch64 and case variants such +// as Darwin select the machine path rather than rootfs. +func isMacOSMachine(meta *containerMetadata) bool { + return Platform{OS: meta.OS}.Normalize().OS == "darwin" +} + +// validateMacOSMachine checks a darwin config's host, platform and labels, so a +// malformed artifact fails before layer unpacking. The labeled files are checked +// when the unpacked bundle is read. +func validateMacOSMachine(meta *containerMetadata) error { + normalized := Platform{OS: meta.OS, Architecture: meta.Architecture, Variant: meta.Variant}.Normalize() + if err := checkMachinePlatformHost(normalized); err != nil { + return err + } + if normalized.Architecture != "arm64" || normalized.Variant != "" { + return fmt.Errorf("macOS machine requires darwin/arm64, not %s", normalized) + } + labels := meta.Labels + for _, want := range [][2]string{{MacOSMachineVersionLabel, "1"}, {MacOSMachineKindLabel, "macos-image"}, {MacOSMachineFormatLabel, "raw"}} { + if label := want[0]; labels[label] != want[1] { + return fmt.Errorf("darwin artifact requires %s=%q, got %q; ordinary container images are not macOS machines", label, want[1], labels[label]) + } + } + pathLabels := []string{MacOSMachineDiskLabel, MacOSMachineAuxLabel, MacOSMachinePlatformLabel} + for i, label := range pathLabels { + path := labels[label] + if path == "" || !filepath.IsLocal(path) { + return fmt.Errorf("darwin artifact %s must be a local relative path, got %q", label, path) + } + for _, other := range pathLabels[:i] { + if filepath.Clean(path) == filepath.Clean(labels[other]) { + return fmt.Errorf("darwin artifact %s and %s name the same file %q", other, label, path) + } + } + } + return nil +} diff --git a/lib/images/macos_machine_test.go b/lib/images/macos_machine_test.go new file mode 100644 index 000000000..f2671a818 --- /dev/null +++ b/lib/images/macos_machine_test.go @@ -0,0 +1,337 @@ +package images + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "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" +) + +func macOSFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + c := MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30} + b, err := json.Marshal(c) + require.NoError(t, err) + // A mostly empty disk, like a real one, so transport can be checked for sparseness. + disk := make([]byte, 64<<20) + copy(disk, "not a real boot disk: synthetic OCI transport fixture") + copy(disk[len(disk)-4:], "tail") + for f, data := range map[string][]byte{"config.json": b, "disk.img": disk, "aux.img": []byte("synthetic aux")} { + require.NoError(t, os.WriteFile(filepath.Join(root, f), data, 0600)) + } + return root +} + +func macOSFixtureMetadata() *containerMetadata { + return &containerMetadata{OS: "darwin", Architecture: "arm64", Labels: map[string]string{ + MacOSMachineVersionLabel: "1", MacOSMachineKindLabel: "macos-image", MacOSMachineFormatLabel: "raw", + MacOSMachineDiskLabel: "disk.img", MacOSMachineAuxLabel: "aux.img", MacOSMachinePlatformLabel: "config.json", + }} +} + +// parseMacOSMachine validates labels and reads the bundle, as a pull does before +// and after unpacking. +func parseMacOSMachine(root string, meta *containerMetadata) (*macOSMachinePayload, error) { + if err := validateMacOSMachine(meta); err != nil { + return nil, err + } + return readMacOSMachine(root, meta) +} + +func TestMacOSMachineValidation(t *testing.T) { + allowMacOSMachines(t) + root := macOSFixture(t) + payload, err := parseMacOSMachine(root, macOSFixtureMetadata()) + require.NoError(t, err) + require.NotNil(t, payload) + for _, tc := range []struct{ name, key, value string }{ + {"unsupported version", MacOSMachineVersionLabel, "2"}, {"container not machine", MacOSMachineKindLabel, ""}, + {"wrong format", MacOSMachineFormatLabel, "qcow2"}, {"absolute path", MacOSMachineDiskLabel, "/etc/passwd"}, + {"traversal", MacOSMachineDiskLabel, "../disk.img"}, {"missing aux", MacOSMachineAuxLabel, "missing.img"}, + {"duplicate file", MacOSMachineAuxLabel, "disk.img"}, + } { + t.Run(tc.name, func(t *testing.T) { + meta := macOSFixtureMetadata() + meta.Labels[tc.key] = tc.value + _, err := parseMacOSMachine(root, meta) + require.Error(t, err) + }) + } + // Platform aliases normalize before the machine check, as they do for manifest matching. + for _, alias := range []struct{ os, arch string }{{"darwin", "aarch64"}, {"Darwin", "arm64"}} { + meta := macOSFixtureMetadata() + meta.OS, meta.Architecture = alias.os, alias.arch + payload, err := parseMacOSMachine(root, meta) + require.NoError(t, err, alias) + require.NotNil(t, payload, alias) + } + require.NoError(t, os.WriteFile(filepath.Join(root, "config.json"), []byte(`{}`), 0600)) + _, err = parseMacOSMachine(root, macOSFixtureMetadata()) + require.Error(t, err) + + // EUI-64 and 20-octet InfiniBand addresses parse with net.ParseMAC but are not Ethernet. + for _, mac := range []string{"02:00:00:00:00:00:00:01", strings.Repeat("00:", 19) + "01"} { + b, err := json.Marshal(MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: mac, CPUs: 4, Memory: 8 << 30}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(root, "config.json"), b, 0600)) + _, err = parseMacOSMachine(root, macOSFixtureMetadata()) + require.Error(t, err, mac) + } + +} + +// TestMacOSMachineOCIRoundTrip pushes a machine bundle to a registry and pulls it +// through the image manager. It is not a VZ boot. +func TestMacOSMachineOCIRoundTrip(t *testing.T) { + allowMacOSMachines(t) + source := macOSFixture(t) + work := t.TempDir() + layerPath := filepath.Join(work, "bundle.tar.gz") + file, err := os.OpenFile(layerPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + require.NoError(t, err) + gz, err := gzip.NewWriterLevel(file, gzip.BestSpeed) + require.NoError(t, err) + tw := tar.NewWriter(gz) + hashes := map[string]string{} + for _, f := range []string{"disk.img", "aux.img", "config.json"} { + input, err := os.Open(filepath.Join(source, f)) + require.NoError(t, err) + info, err := input.Stat() + require.NoError(t, err) + // The registry controls modes; a permissive disk must not become the canonical image mode. + mode := int64(0600) + if f == "disk.img" { + mode = 0666 + } + require.NoError(t, tw.WriteHeader(&tar.Header{Name: f, Mode: mode, Size: info.Size(), Typeflag: tar.TypeReg})) + h := sha256.New() + _, err = io.Copy(io.MultiWriter(tw, h), input) + require.NoError(t, err) + require.NoError(t, input.Close()) + hashes[f] = fmt.Sprintf("%x", h.Sum(nil)) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + require.NoError(t, file.Close()) + layer, err := tarball.LayerFromFile(layerPath) + require.NoError(t, err) + image, err := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, err) + cfg, err := image.ConfigFile() + require.NoError(t, err) + cfg.OS = "darwin" + cfg.Architecture = "arm64" + cfg.Config.Labels = macOSFixtureMetadata().Labels + image, err = mutate.ConfigFile(image, cfg) + require.NoError(t, err) + server := httptest.NewServer(registry.New()) + defer server.Close() + ref, err := name.ParseReference(strings.TrimPrefix(server.URL, "http://") + "/macos:spike") + require.NoError(t, err) + require.NoError(t, remote.Write(ref, image)) + p := paths.New(filepath.Join(work, "hypeman-data")) + manager, err := NewManager(p, 1, nil) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + pulled, err := manager.CreateImage(ctx, CreateImageRequest{Name: ref.Name(), Platform: "darwin/arm64"}) + require.NoError(t, err) + require.NoError(t, manager.WaitForReady(ctx, pulled.Name)) + pulled, err = manager.GetImage(ctx, pulled.Name) + require.NoError(t, err) + require.Equal(t, StatusReady, pulled.Status) + require.Equal(t, "darwin/arm64", pulled.Platform) + require.NotNil(t, pulled.MacOS) + disk, err := GetDiskPath(p, pulled.Name, pulled.Digest) + require.NoError(t, err) + require.Equal(t, machineDiskFile, filepath.Base(disk), "a raw machine disk is not named as a filesystem") + for f, path := range map[string]string{"disk.img": disk, "aux.img": filepath.Join(filepath.Dir(disk), "aux.img")} { + sum, err := fileSHA256(ctx, path) + require.NoError(t, err) + require.Equal(t, "sha256:"+hashes[f], sum, f) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm(), f) + if f == "disk.img" { + control := sparseControlBytes(t, filepath.Dir(path), info.Size()) + require.LessOrEqual(t, allocatedBytes(t, path), control+2*machineUnpackChunk, "the pulled disk stays sparse") + } + } + expected, err := os.ReadFile(filepath.Join(source, "config.json")) + require.NoError(t, err) + var config MacOSImage + require.NoError(t, json.Unmarshal(expected, &config)) + require.Equal(t, config, *pulled.MacOS) + digest, err := image.Digest() + require.NoError(t, err) + require.Equal(t, digest.String(), pulled.Digest) + reused, err := manager.CreateImage(ctx, CreateImageRequest{Name: ref.Name(), Platform: "darwin/arm64"}) + require.NoError(t, err) + require.Equal(t, StatusReady, reused.Status) + tagged, err := manager.TagImage(ctx, ref.Name(), ref.Context().Name()+":stable") + require.NoError(t, err) + require.Equal(t, pulled.Digest, tagged.Digest) + require.Equal(t, pulled.MacOS, tagged.MacOS) + tagDisk, err := GetDiskPath(p, tagged.Name, tagged.Digest) + require.NoError(t, err) + require.Equal(t, disk, tagDisk) + _, err = manager.TagImage(ctx, ref.Name(), strings.TrimPrefix(server.URL, "http://")+"/other:stable") + require.ErrorIs(t, err, ErrInvalidPlatform) + _, err = manager.CreateImage(ctx, CreateImageRequest{Name: ref.Name(), Platform: "linux/arm64"}) + require.ErrorIs(t, err, ErrInvalidPlatform) + t.Log("round-trip verified", pulled.Digest) +} + +func TestValidateMacOSMachineRejectsBeforeUnpack(t *testing.T) { + allowMacOSMachines(t) + valid := map[string]string{MacOSMachineVersionLabel: "1", MacOSMachineKindLabel: "macos-image", MacOSMachineFormatLabel: "raw", + MacOSMachineDiskLabel: "disk.img", MacOSMachineAuxLabel: "aux.img", MacOSMachinePlatformLabel: "platform.json"} + require.False(t, isMacOSMachine(&containerMetadata{OS: "linux", Architecture: "amd64"})) + require.True(t, isMacOSMachine(&containerMetadata{OS: "Darwin", Architecture: "aarch64"})) + require.NoError(t, validateMacOSMachine(&containerMetadata{OS: "darwin", Architecture: "arm64", Labels: valid})) + require.Error(t, validateMacOSMachine(&containerMetadata{OS: "darwin", Architecture: "arm64"}), "darwin artifact without machine labels must fail before layer unpack") + require.ErrorContains(t, validateMacOSMachine(&containerMetadata{OS: "darwin", Architecture: "amd64", Labels: valid}), "darwin/amd64") + + for name, change := range map[string]func(map[string]string){ + "missing disk path": func(l map[string]string) { delete(l, MacOSMachineDiskLabel) }, + "missing aux path": func(l map[string]string) { delete(l, MacOSMachineAuxLabel) }, + "missing platform path": func(l map[string]string) { delete(l, MacOSMachinePlatformLabel) }, + "absolute path": func(l map[string]string) { l[MacOSMachineDiskLabel] = "/disk.img" }, + "escaping path": func(l map[string]string) { l[MacOSMachineAuxLabel] = "../aux.img" }, + "duplicate path": func(l map[string]string) { l[MacOSMachineAuxLabel] = "./disk.img" }, + } { + labels := maps.Clone(valid) + change(labels) + require.Error(t, validateMacOSMachine(&containerMetadata{OS: "darwin", Architecture: "arm64", Labels: labels}), name) + } +} + +func allowMacOSMachines(t *testing.T) { + prev := macOSMachineHost + macOSMachineHost = true + t.Cleanup(func() { macOSMachineHost = prev }) +} + +func TestMacOSMachinePullRefusedOnOtherHosts(t *testing.T) { + prev := macOSMachineHost + macOSMachineHost = false + t.Cleanup(func() { macOSMachineHost = prev }) + + require.ErrorIs(t, checkMachinePlatformHost(Platform{OS: "darwin", Architecture: "arm64"}), ErrInvalidPlatform) + require.NoError(t, checkMachinePlatformHost(Platform{OS: "linux", Architecture: "arm64"})) + require.ErrorIs(t, validateMacOSMachine(&containerMetadata{OS: "darwin", Architecture: "arm64"}), ErrInvalidPlatform, "a darwin manifest pulled without a platform is refused too") +} + +// A host that cannot boot macOS refuses a darwin image without downloading its +// layers, including when the request names no platform. +func TestMacOSMachineRefusedBeforeLayerDownloadOnOtherHosts(t *testing.T) { + prev := macOSMachineHost + macOSMachineHost = false + t.Cleanup(func() { macOSMachineHost = prev }) + + layer, err := random.Layer(1<<10, types.DockerLayer) + require.NoError(t, err) + image, err := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, err) + cfg, err := image.ConfigFile() + require.NoError(t, err) + cfg.OS, cfg.Architecture, cfg.Config.Labels = "darwin", "arm64", macOSFixtureMetadata().Labels + image, err = mutate.ConfigFile(image, cfg) + require.NoError(t, err) + layerDigest, err := layer.Digest() + require.NoError(t, err) + + var layerFetches atomic.Int32 + reg := registry.New() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/blobs/"+layerDigest.String()) { + layerFetches.Add(1) + } + reg.ServeHTTP(w, r) + })) + defer server.Close() + ref, err := name.ParseReference(strings.TrimPrefix(server.URL, "http://") + "/macos:spike") + require.NoError(t, err) + require.NoError(t, remote.Write(ref, image)) + digest, err := image.Digest() + require.NoError(t, err) + + manager, err := NewManager(paths.New(t.TempDir()), 1, nil) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + _, err = manager.CreateImage(ctx, CreateImageRequest{Name: ref.Context().Name() + "@" + digest.String()}) + require.ErrorIs(t, err, ErrInvalidPlatform, "a digest names its platform, so the request is refused at once") + + pending, err := manager.CreateImage(ctx, CreateImageRequest{Name: ref.Name()}) + require.NoError(t, err, "a tag's platform is only known once the worker reads the config") + err = manager.WaitForReady(ctx, pending.Name) + require.Error(t, err) + require.Contains(t, err.Error(), "macOS host") + require.Zero(t, layerFetches.Load(), "the layer was downloaded before the refusal") +} + +// An in-flight darwin pull knows only the platform it was requested as. An explicit +// request for another platform must not be handed that pull. +func TestMacOSMachineInFlightPullRefusesOtherPlatform(t *testing.T) { + allowMacOSMachines(t) + layer, err := random.Layer(1<<10, types.DockerLayer) + require.NoError(t, err) + image, err := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, err) + cfg, err := image.ConfigFile() + require.NoError(t, err) + cfg.OS, cfg.Architecture, cfg.Config.Labels = "darwin", "arm64", macOSFixtureMetadata().Labels + image, err = mutate.ConfigFile(image, cfg) + require.NoError(t, err) + server := httptest.NewServer(registry.New()) + defer server.Close() + ref, err := name.ParseReference(strings.TrimPrefix(server.URL, "http://") + "/macos:spike") + require.NoError(t, err) + require.NoError(t, remote.Write(ref, image)) + digest, err := image.Digest() + require.NoError(t, err) + + p := paths.New(t.TempDir()) + manager, err := NewManager(p, 1, nil) + require.NoError(t, err) + normalized, err := ParseNormalizedRef(ref.Name()) + require.NoError(t, err) + // A darwin pull of this digest is still downloading. + require.NoError(t, writeMetadata(p, normalized.Repository(), digest.Hex, &imageMetadata{ + Name: ref.Name(), Digest: digest.String(), Status: StatusPulling, BuildID: "in-flight", + Request: &CreateImageRequest{Name: ref.Name(), Platform: "darwin/arm64"}, + })) + + ctx := context.Background() + _, err = manager.CreateImage(ctx, CreateImageRequest{Name: ref.Name(), Platform: "linux/arm64"}) + require.ErrorIs(t, err, ErrInvalidPlatform) + pending, err := manager.CreateImage(ctx, CreateImageRequest{Name: ref.Name(), Platform: "darwin/arm64"}) + require.NoError(t, err, "a matching request still joins the in-flight pull") + require.Equal(t, StatusPulling, pending.Status) +} diff --git a/lib/images/macos_stage_test.go b/lib/images/macos_stage_test.go new file mode 100644 index 000000000..5acd8e0ad --- /dev/null +++ b/lib/images/macos_stage_test.go @@ -0,0 +1,28 @@ +package images + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStageMachineFileNeverKeepsRegistryMode(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "source.raw") + require.NoError(t, os.WriteFile(src, []byte("synthetic"), 0644)) + require.NoError(t, os.Chmod(src, 0644)) + dst := filepath.Join(root, "staged.raw") + size, err := stageMachineFile(src, dst) + require.NoError(t, err) + require.EqualValues(t, len("synthetic"), size) + info, err := os.Stat(dst) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + entries, err := os.ReadDir(root) + require.NoError(t, err) + for _, entry := range entries { + require.NotContains(t, entry.Name(), ".install-", "private staging directory leaked") + } +} diff --git a/lib/images/macos_unpack.go b/lib/images/macos_unpack.go new file mode 100644 index 000000000..ad08493ec --- /dev/null +++ b/lib/images/macos_unpack.go @@ -0,0 +1,174 @@ +package images + +import ( + "archive/tar" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" +) + +// machineUnpackChunk is the granularity of hole detection when writing machine files. +const machineUnpackChunk = 64 << 10 + +var machineZeroChunk = make([]byte, machineUnpackChunk) + +// maxMachineUnpackedBytes caps the decompressed bytes of all of a machine image's +// layers, so a small layer cannot expand without bound. Zero chunks become holes, +// so the cap bounds decompression and hashing work as much as disk use. Tests +// lower it. +var maxMachineUnpackedBytes int64 = 1 << 40 + +// unpackMachineLayers extracts a macOS machine image's layers into targetDir. Unlike +// the rootfs unpack, it writes files sparsely: zero chunks become holes, so a +// mostly empty boot disk occupies only its data. Machine bundles hold only regular +// files and directories; any other entry is rejected. Each layer is checked +// against its config diff ID. +func (c *ociClient) unpackMachineLayers(ctx context.Context, layoutTag, targetDir string) error { + path, err := layout.FromPath(c.cacheDir) + if err != nil { + return fmt.Errorf("open oci layout: %w", err) + } + img, err := imageByAnnotation(path, layoutTag) + if err != nil { + return fmt.Errorf("find image by tag %s: %w", layoutTag, err) + } + manifest, 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, manifest, configFile); err != nil { + return err + } + layers, err := img.Layers() + if err != nil { + return fmt.Errorf("get layers: %w", err) + } + if err := os.MkdirAll(targetDir, 0700); err != nil { + return fmt.Errorf("create target dir: %w", err) + } + remaining := maxMachineUnpackedBytes + for i, l := range layers { + if err := unpackMachineLayer(ctx, l, configFile.RootFS.DiffIDs[i], targetDir, &remaining); err != nil { + return fmt.Errorf("unpack machine layer %d: %w", i, err) + } + } + return nil +} + +// unpackMachineLayer extracts one layer, spending its decompressed bytes from the +// bundle's remaining budget. +func unpackMachineLayer(ctx context.Context, l gcr.Layer, diffID gcr.Hash, targetDir string, remaining *int64) error { + rc, err := l.Uncompressed() + if err != nil { + return err + } + defer rc.Close() + hash := sha256.New() + // One byte past the budget distinguishes an exact fit from an overrun. + limited := &io.LimitedReader{R: contextReader{ctx: ctx, reader: rc}, N: *remaining + 1} + defer func() { *remaining -= *remaining + 1 - limited.N }() + stream := io.TeeReader(limited, hash) + // A read cut short by the limit surfaces as a truncated archive; report the limit. + overLimit := func(err error) error { + if limited.N == 0 { + return fmt.Errorf("machine image exceeds the %d-byte unpacked size limit", maxMachineUnpackedBytes) + } + return err + } + archive := tar.NewReader(stream) + for { + header, err := archive.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return overLimit(err) + } + name := filepath.Clean(header.Name) + if !filepath.IsLocal(name) || strings.HasPrefix(filepath.Base(name), ".wh.") { + return fmt.Errorf("machine layer entry %q is not a plain local path", header.Name) + } + target := filepath.Join(targetDir, name) + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0700); err != nil { + return err + } + case tar.TypeReg: + // The tar reader expands GNU sparse entries itself, writing holes it never + // reads, so their size would escape the byte budget. Bundles never need them. + if isGNUSparse(header) { + return fmt.Errorf("machine layer entry %q is a GNU sparse tar entry, which machine images do not support", header.Name) + } + if header.Size >= limited.N { + return fmt.Errorf("machine layer entry %q declares %d bytes, over the %d-byte unpacked size limit", header.Name, header.Size, maxMachineUnpackedBytes) + } + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + if err := writeSparseFile(target, archive, header.Size); err != nil { + return overLimit(fmt.Errorf("write %s: %w", name, err)) + } + default: + return fmt.Errorf("machine layer entry %q has unsupported type %q", header.Name, header.Typeflag) + } + } + // Hash the trailing tar padding too, so the digest covers the whole stream. + if _, err := io.Copy(io.Discard, stream); err != nil { + return overLimit(err) + } + if err := overLimit(nil); err != nil { + return err + } + if got := hex.EncodeToString(hash.Sum(nil)); got != diffID.Hex { + return fmt.Errorf("layer content sha256:%s does not match diff ID %s", got, diffID) + } + return nil +} + +// isGNUSparse reports whether a tar entry carries PAX GNU sparse records. The older +// GNU sparse type keeps its own typeflag and is rejected as unsupported. +func isGNUSparse(header *tar.Header) bool { + for key := range header.PAXRecords { + if strings.HasPrefix(key, "GNU.sparse.") { + return true + } + } + return false +} + +// writeSparseFile writes size bytes from r to path. The file is sized first and +// only nonzero chunks are written, so zero chunks stay holes. +func writeSparseFile(path string, r io.Reader, size int64) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + return err + } + err = file.Truncate(size) + buf := make([]byte, machineUnpackChunk) + for offset := int64(0); err == nil && offset < size; { + chunk := buf[:min(int64(len(buf)), size-offset)] + if _, err = io.ReadFull(r, chunk); err != nil { + break + } + if !bytes.Equal(chunk, machineZeroChunk[:len(chunk)]) { + _, err = file.WriteAt(chunk, offset) + } + offset += int64(len(chunk)) + } + return errors.Join(err, file.Close()) +} diff --git a/lib/images/macos_unpack_test.go b/lib/images/macos_unpack_test.go new file mode 100644 index 000000000..a88014169 --- /dev/null +++ b/lib/images/macos_unpack_test.go @@ -0,0 +1,233 @@ +package images + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/stretchr/testify/require" +) + +func machineTestLayer(t *testing.T, entries ...tar.Header) gcr.Layer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, h := range entries { + data := make([]byte, h.Size) + if h.Typeflag == tar.TypeReg && h.Size > 0 { + copy(data, "head") + copy(data[len(data)-4:], "tail") + } + require.NoError(t, tw.WriteHeader(&h)) + _, err := tw.Write(data) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(buf.Bytes())), nil }) + require.NoError(t, err) + return layer +} + +// allocatedBytes reports the bytes a file occupies on disk. +func allocatedBytes(t *testing.T, path string) int64 { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + return info.Sys().(*syscall.Stat_t).Blocks * 512 +} + +// sparseControlBytes reports what a file with data only at its ends occupies in +// dir. Filesystems differ in which holes they keep, so sparseness is checked +// against this control rather than a fixed ratio. +func sparseControlBytes(t *testing.T, dir string, size int64) int64 { + t.Helper() + path := filepath.Join(dir, "sparse-control") + file, err := os.Create(path) + require.NoError(t, err) + require.NoError(t, file.Truncate(size)) + _, err = file.WriteAt([]byte("head"), 0) + require.NoError(t, err) + _, err = file.WriteAt([]byte("tail"), size-4) + require.NoError(t, err) + require.NoError(t, file.Close()) + allocated := allocatedBytes(t, path) + require.NoError(t, os.Remove(path)) + if allocated >= size/2 { + t.Skipf("filesystem does not keep holes in a %d-byte file", size) + } + return allocated +} + +func TestUnpackMachineLayerWritesSparseFiles(t *testing.T) { + size := int64(64 << 20) + layer := machineTestLayer(t, tar.Header{Name: "bundle/", Typeflag: tar.TypeDir, Mode: 0755}, tar.Header{Name: "bundle/disk.img", Typeflag: tar.TypeReg, Mode: 0644, Size: size}) + diffID, err := layer.DiffID() + require.NoError(t, err) + target := t.TempDir() + require.NoError(t, unpackMachineLayer(context.Background(), layer, diffID, target, unlimitedMachineBudget())) + + path := filepath.Join(target, "bundle", "disk.img") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Len(t, data, int(size)) + require.Equal(t, "head", string(data[:4])) + require.Equal(t, "tail", string(data[size-4:])) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + control := sparseControlBytes(t, target, size) + require.LessOrEqual(t, allocatedBytes(t, path), control+2*machineUnpackChunk, "zero chunks must become holes") +} + +func TestUnpackMachineLayerRejectsUnsafeEntries(t *testing.T) { + for name, entry := range map[string]tar.Header{ + "symlink": {Name: "disk.img", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}, + "hardlink": {Name: "disk.img", Typeflag: tar.TypeLink, Linkname: "other"}, + "escape": {Name: "../disk.img", Typeflag: tar.TypeReg, Size: 4}, + "absolute": {Name: "/disk.img", Typeflag: tar.TypeReg, Size: 4}, + "whiteout": {Name: ".wh.disk.img", Typeflag: tar.TypeReg}, + } { + t.Run(name, func(t *testing.T) { + layer := machineTestLayer(t, entry) + diffID, err := layer.DiffID() + require.NoError(t, err) + parent := t.TempDir() + target := filepath.Join(parent, "rootfs") + require.NoError(t, os.Mkdir(target, 0700)) + require.Error(t, unpackMachineLayer(context.Background(), layer, diffID, target, unlimitedMachineBudget())) + _, err = os.Lstat(filepath.Join(parent, "disk.img")) + require.True(t, os.IsNotExist(err), "nothing is written outside the target") + }) + } +} + +func TestUnpackMachineLayerVerifiesDiffID(t *testing.T) { + layer := machineTestLayer(t, tar.Header{Name: "disk.img", Typeflag: tar.TypeReg, Size: 16}) + other := machineTestLayer(t, tar.Header{Name: "disk.img", Typeflag: tar.TypeReg, Size: 32}) + wrong, err := other.DiffID() + require.NoError(t, err) + require.ErrorContains(t, unpackMachineLayer(context.Background(), layer, wrong, t.TempDir(), unlimitedMachineBudget()), "does not match diff ID") +} + +func unlimitedMachineBudget() *int64 { + remaining := maxMachineUnpackedBytes + return &remaining +} + +func TestUnpackMachineLayerEnforcesUnpackedSizeLimit(t *testing.T) { + const budget = 1 << 20 + layerFromBytes := func(data []byte) gcr.Layer { + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }) + require.NoError(t, err) + return layer + } + unpack := func(layer gcr.Layer, target string, remaining *int64) error { + diffID, err := layer.DiffID() + require.NoError(t, err) + return unpackMachineLayer(context.Background(), layer, diffID, target, remaining) + } + + t.Run("declared size over the limit", func(t *testing.T) { + remaining := int64(budget) + target := t.TempDir() + err := unpack(machineTestLayer(t, tar.Header{Name: "disk.img", Typeflag: tar.TypeReg, Size: 2 * budget}), target, &remaining) + require.ErrorContains(t, err, "unpacked size limit") + require.NoFileExists(t, filepath.Join(target, "disk.img"), "no file is sized before the check") + }) + + t.Run("data after the end of the archive", func(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "aux.img", Typeflag: tar.TypeReg, Size: 4})) + _, err := tw.Write([]byte("data")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + buf.Write(bytes.Repeat([]byte{0xff}, 2*budget)) + remaining := int64(budget) + require.ErrorContains(t, unpack(layerFromBytes(buf.Bytes()), t.TempDir(), &remaining), "unpacked size limit") + }) + + t.Run("budget is shared across layers", func(t *testing.T) { + remaining := int64(budget) + target := t.TempDir() + half := int64(budget * 6 / 10) + require.NoError(t, unpack(machineTestLayer(t, tar.Header{Name: "disk.img", Typeflag: tar.TypeReg, Size: half}), target, &remaining)) + require.Less(t, remaining, int64(budget-half), "the first layer spent its bytes") + require.ErrorContains(t, unpack(machineTestLayer(t, tar.Header{Name: "aux.img", Typeflag: tar.TypeReg, Size: half}), target, &remaining), "unpacked size limit") + }) +} + +// paxGNUSparseArchive returns count PAX GNU sparse 1.0 entries, each a 4-byte file +// that the tar reader expands to size bytes of mostly holes. archive/tar cannot write +// sparse entries, so each PAX header is written as a regular entry and retyped. +func paxGNUSparseArchive(t *testing.T, count int, size int64) []byte { + t.Helper() + record := func(k, v string) string { + n := len(k) + len(v) + 3 + for { + s := fmt.Sprintf("%d %s=%s\n", n, k, v) + if len(s) == n { + return s + } + n = len(s) + } + } + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for i := range count { + name := fmt.Sprintf("disk-%d.img", i) + pax := record("GNU.sparse.major", "1") + record("GNU.sparse.minor", "0") + record("GNU.sparse.name", name) + record("GNU.sparse.realsize", strconv.FormatInt(size, 10)) + require.NoError(t, tw.Flush()) + start := buf.Len() + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "PaxHeaders/" + name, Typeflag: tar.TypeReg, Size: int64(len(pax)), Format: tar.FormatUSTAR})) + _, err := tw.Write([]byte(pax)) + require.NoError(t, err) + require.NoError(t, tw.Flush()) + header := buf.Bytes()[start : start+512] + header[156] = tar.TypeXHeader + copy(header[148:156], " ") + sum := 0 + for _, b := range header { + sum += int(b) + } + copy(header[148:156], fmt.Sprintf("%06o\x00 ", sum)) + // Sparse 1.0 data starts with the map, padded to a block: one 4-byte region at 0. + body := make([]byte, 512, 516) + copy(body, "1\n0\n4\n") + body = append(body, "data"...) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "GNUSparseFile.0/" + name, Typeflag: tar.TypeReg, Size: int64(len(body)), Format: tar.FormatUSTAR})) + _, err = tw.Write(body) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + return buf.Bytes() +} + +func TestUnpackMachineLayerRejectsGNUSparseEntries(t *testing.T) { + data := paxGNUSparseArchive(t, 16, 512<<10) + reader := tar.NewReader(bytes.NewReader(data)) + header, err := reader.Next() + require.NoError(t, err) + require.True(t, isGNUSparse(header), "the fixture must read back as a sparse entry") + require.EqualValues(t, 512<<10, header.Size) + + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }) + require.NoError(t, err) + diffID, err := layer.DiffID() + require.NoError(t, err) + remaining := int64(1 << 20) + target := t.TempDir() + require.ErrorContains(t, unpackMachineLayer(context.Background(), layer, diffID, target, &remaining), "GNU sparse") + entries, err := os.ReadDir(target) + require.NoError(t, err) + require.Empty(t, entries, "no sparse entry is expanded") +} diff --git a/lib/images/manager.go b/lib/images/manager.go index 5ccd7b7e7..6618b3176 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -173,9 +173,8 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima if err != nil { return nil, err } - - if platform.OS == "darwin" { - return nil, fmt.Errorf("%w: macOS images must be imported locally with import-macos, not pulled as Linux containers", ErrInvalidPlatform) + if err := checkMachinePlatformHost(platform); err != nil { + return nil, err } // Parse and normalize @@ -211,6 +210,11 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima if err := validateDigestPlatform(req.Platform, platform, actual); err != nil { return nil, err } + // A digest names its platform, so a darwin image is refused here even when + // the request named none, instead of failing later in the worker. + if err := checkMachinePlatformHost(actual); err != nil { + return nil, err + } } else { // inspectManifestWithPlatform already classifies registry errors via // ClassifyRegistryError, so just propagate with %w to keep the errors.Is chain. @@ -223,6 +227,11 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima m.createMu.Lock() defer m.createMu.Unlock() + if strings.TrimSpace(req.Platform) != "" { + if err := checkCachedMachinePlatform(m.paths, ref, platform); err != nil { + return nil, err + } + } if img, found, err := m.reuseExistingImage(ref, req.Credentials, req.Tags); found || err != nil { return img, err } @@ -509,22 +518,29 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials m.updateStatusByDigest(ref, StatusConverting, nil, buildID) - diskPath := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()).disk + layout := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()) + diskPath := layout.disk // Keep the temporary filesystem beside its final path so finalization stays // atomic even when system/builds and images are on different filesystems. - diskTempPath := diskPath + ".tmp-" + buildID - defer os.Remove(diskTempPath) - // Use default image format (erofs on Linux, ext4 on Darwin) - convertStart := time.Now() - diskSize, err := ExportRootfs(tempDir, diskTempPath, DefaultImageFormat) - m.recordImageBuildPhase(ctx, ref.Digest(), "filesystem_export", time.Since(convertStart), phaseStatus(err), "not_applicable") + staged, err := m.stageMacOSOutputs(ctx, ref.Digest(), tempDir, result.Metadata, layout.dir, diskPath+".tmp-"+buildID, buildID) + defer staged.remove() if err != nil { - m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("convert to %s: %w", DefaultImageFormat, err), buildID) + m.updateStatusByDigest(ref, StatusFailed, err, buildID) return } + if staged.macos == nil { + // Use default image format (erofs on Linux, ext4 on Darwin) + convertStart := time.Now() + staged.sizeBytes, err = ExportRootfs(tempDir, staged.disk, DefaultImageFormat) + m.recordImageBuildPhase(ctx, ref.Digest(), "filesystem_export", time.Since(convertStart), phaseStatus(err), "not_applicable") + if err != nil { + m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("convert to %s: %w", DefaultImageFormat, err), buildID) + return + } + } finalizeStart := time.Now() - err = m.finalizeImage(ref, result, diskSize, buildID, diskTempPath) + err = m.finalizeImage(ref, result, buildID, staged) m.recordImageBuildPhase(ctx, ref.Digest(), "finalize", time.Since(finalizeStart), phaseStatus(err), "not_applicable") if err != nil { if errors.Is(err, errStaleBuild) { @@ -537,94 +553,6 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials buildStatus = "success" } -func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, buildID, diskTempPath string) error { - m.createMu.Lock() - defer m.createMu.Unlock() - - layout := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()) - - // Read current metadata to preserve request info and reject stale builds. - meta, err := readMetadataAt(layout) - if err != nil || meta.BuildID != buildID { - return errStaleBuild - } - - // The pulled image config is the source of truth for the platform. - var requestedPlatform string - if meta.Request != nil { - requestedPlatform = meta.Request.Platform - } - actualPlatform, err := resolveManifestPlatform(result.Metadata, requestedPlatform) - if err != nil { - return err - } - - modelPath := manifestModelPath(m.paths, layout, ref.DigestHex()) - diskInstalled := false - modelWritten := false - - if err := installAtomically(layout.disk, func(path string) error { - return os.Rename(diskTempPath, path) - }); err != nil { - return fmt.Errorf("install image disk: %w", err) - } - diskInstalled = true - - // Persist the manifest content model beside the shared content so later - // stages can recompose the image from per-layer artifacts and GC can tell - // which OCI blobs are still referenced. - if result.Manifest != nil { - model := *result.Manifest - model.Platform = actualPlatform.String() - if err := writeManifestModelAt(modelPath, ref.DigestHex(), &model); err != nil { - return rollbackFinalization(layout, modelPath, diskInstalled, modelWritten, fmt.Errorf("write manifest model: %w", err)) - } - modelWritten = true - } - - meta.Status = StatusReady - meta.Error = nil - meta.Platform = actualPlatform.String() - meta.SizeBytes = diskSize - meta.Entrypoint = result.Metadata.Entrypoint - meta.Cmd = result.Metadata.Cmd - meta.Env = result.Metadata.Env - meta.Labels = result.Metadata.Labels - meta.WorkingDir = result.Metadata.WorkingDir - - if err := writeMetadataFile(layout.metadata, meta); err != nil { - return rollbackFinalization(layout, modelPath, diskInstalled, modelWritten, fmt.Errorf("write final metadata: %w", err)) - } - - m.notifyReady(ref.DigestHex(), StatusReady, nil) - if !m.claimRequestedTags(ref, meta) { - m.cleanupUnclaimedImage(ref) - } - m.layers.refreshDiskUsageTotals() - return nil -} - -func manifestModelPath(p *paths.Paths, layout imageLayout, digestHex string) string { - if layout.content { - return p.ImageContentManifestModel(digestHex) - } - return filepath.Join(layout.dir, "manifest.json") -} - -func rollbackFinalization(layout imageLayout, modelPath string, diskInstalled, modelWritten bool, cause error) error { - var rollbackErr error - if modelWritten { - rollbackErr = errors.Join(rollbackErr, os.Remove(modelPath)) - } - if diskInstalled { - rollbackErr = errors.Join(rollbackErr, os.Remove(layout.disk)) - } - if rollbackErr != nil { - return errors.Join(cause, fmt.Errorf("rollback finalization: %w", rollbackErr)) - } - return cause -} - func phaseStatus(err error) string { if err != nil { return "failed" diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 13a6835e2..23404d5a8 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -736,7 +736,7 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { m.updateStatusByDigest(staleRef, StatusFailed, errors.New("stale build"), firstMeta.BuildID) staleBundle, err := m.ociClient.extractOCIImageBundle(digestHex) require.NoError(t, err) - require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleBundle.Meta}, 1, firstMeta.BuildID, ""), errStaleBuild) + require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleBundle.Meta}, firstMeta.BuildID, stagedImageFiles{sizeBytes: 1}), errStaleBuild) currentMeta, err = readMetadata(p, repo, digestHex) require.NoError(t, err) require.Equal(t, StatusPending, currentMeta.Status) diff --git a/lib/images/oci.go b/lib/images/oci.go index c230db19f..3a3ad743f 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -278,14 +278,25 @@ func (c *ociClient) pullAndExportWithPlatformAuth(ctx context.Context, imageRef, if err != nil { return result, fmt.Errorf("extract metadata: %w", err) } + // Reject malformed darwin artifacts before any layer is unpacked. + machine := isMacOSMachine(bundle.Meta) + if machine { + if err := validateMacOSMachine(bundle.Meta); err != nil { + return result, err + } + } result.Metadata = bundle.Meta result.Manifest = bundle.Model result.LayerCount = bundle.LayerCount result.CompressedBytes = bundle.CompressedBytes // Unpack layers to the export directory + unpack := c.unpackLayers + if machine { + unpack = c.unpackMachineLayers + } if err := result.measure("layer_unpack", func() error { - return c.unpackLayers(ctx, layoutTag, exportDir) + return unpack(ctx, layoutTag, exportDir) }); err != nil { return result, fmt.Errorf("unpack layers: %w", err) } @@ -317,6 +328,9 @@ func (c *ociClient) pullToOCILayoutWithPlatformAuth(ctx context.Context, imageRe // Rate limits fail here immediately (429 is not retried by default) return fmt.Errorf("fetch image manifest: %w", ClassifyRegistryError(err)) } + if err := refuseMachineOnOtherHost(img); err != nil { + return err + } err = ocicache.AppendImage(c.cacheDir, img, layoutTag) if err != nil { diff --git a/lib/images/platform.go b/lib/images/platform.go index 28e788b26..bbd5c7874 100644 --- a/lib/images/platform.go +++ b/lib/images/platform.go @@ -86,7 +86,7 @@ func (p Platform) validate() error { return nil } if p.OS != "linux" { - return fmt.Errorf("%w: unsupported os %q: use linux or locally imported darwin/arm64", ErrInvalidPlatform, p.OS) + return fmt.Errorf("%w: unsupported os %q: use linux or darwin/arm64", ErrInvalidPlatform, p.OS) } switch p.Architecture { case "amd64", "arm64": @@ -167,10 +167,6 @@ func resolveManifestPlatform(meta *containerMetadata, requested string) (Platfor Variant: meta.Variant, }.Normalize() - if actual.OS == "darwin" { - return Platform{}, fmt.Errorf("%w: macOS must be imported as a local disk bundle", ErrInvalidPlatform) - } - // An explicit request is authoritative for the match check and, when the // manifest omits its own platform, for the recorded value too. if strings.TrimSpace(requested) != "" { diff --git a/lib/images/storage.go b/lib/images/storage.go index d4f6b0c7f..f9f9de89c 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -133,12 +133,12 @@ func resolveImageLayout(p *paths.Paths, repository, digestHex string) imageLayou } func contentLayout(p *paths.Paths, digestHex string) imageLayout { - return imageLayout{ + return withMachineDisk(imageLayout{ dir: p.ImageContentDir(digestHex), metadata: p.ImageContentMetadata(digestHex), disk: p.ImageContentPath(digestHex), content: true, - } + }) } func pathExists(path string) bool { @@ -151,10 +151,17 @@ func digestDir(p *paths.Paths, repository, digestHex string) string { } func legacyLayout(p *paths.Paths, repository, digestHex string) imageLayout { - layout := imageLayout{dir: p.ImageDigestDir(repository, digestHex), metadata: p.ImageMetadata(repository, digestHex), disk: p.ImageDigestPath(repository, digestHex)} - // Locally imported machine images keep a raw disk beside their metadata. Linux - // legacy layouts never have one, so a stat is enough to pick the file. - if raw := filepath.Join(layout.dir, "rootfs.raw"); pathExists(raw) { + return withMachineDisk(imageLayout{dir: p.ImageDigestDir(repository, digestHex), metadata: p.ImageMetadata(repository, digestHex), disk: p.ImageDigestPath(repository, digestHex)}) +} + +// machineDiskFile is a macOS machine image's raw boot disk, whether imported locally +// or pulled. Rootfs images use erofs or ext4 instead. +const machineDiskFile = "rootfs.raw" + +// withMachineDisk points layout at a machine image's raw disk when the directory has +// one. Rootfs layouts never do, so a stat is enough to pick the file. +func withMachineDisk(layout imageLayout) imageLayout { + if raw := filepath.Join(layout.dir, machineDiskFile); pathExists(raw) { layout.disk = raw } return layout diff --git a/lib/images/tag.go b/lib/images/tag.go index 3a497a6a9..614ac4f14 100644 --- a/lib/images/tag.go +++ b/lib/images/tag.go @@ -28,8 +28,8 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, if err != nil { return nil, err } - if meta.MacOS != nil { - return nil, fmt.Errorf("%w: tagging macOS bundles is not implemented", ErrInvalidPlatform) + if meta.MacOS != nil && sourceRef.Repository() != targetRef.Repository() { + return nil, fmt.Errorf("%w: cross-repository promotion of macOS bundles is not implemented", ErrInvalidPlatform) } if err := m.cancelPendingTag(targetRef.Repository(), targetRef.Tag()); err != nil { return nil, fmt.Errorf("cancel pending image tag: %w", err)