Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions docs/macos-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/diskutilization/diskutilization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/diskutilization/diskutilization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions lib/images/finalization_rollback_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
115 changes: 115 additions & 0 deletions lib/images/finalize.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 4 additions & 3 deletions lib/images/macos_import_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading
Loading