Skip to content

Commit aa7872f

Browse files
committed
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.
1 parent fe64b62 commit aa7872f

12 files changed

Lines changed: 508 additions & 45 deletions

‎docs/macos-experimental.md‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,37 @@ go run -tags containers_image_openpgp ./cmd/import-macos \
2020
--name localhost/macos:spike
2121
```
2222

23-
The data directory needs APFS clonefile support. Images are `darwin/arm64`, not
24-
OCI macOS containers. Registry pulls, manifest resolution and tagging/promotion
25-
of macOS images are unsupported. Do not mutate the imported image files.
23+
The local importer needs APFS clonefile support. Images are `darwin/arm64`
24+
machine images, not OCI macOS containers. Registry pulls also support experimental
25+
complete-machine bundles described below. Additional tags within the same
26+
repository are supported; cross-repository promotion remains unsupported.
27+
Do not mutate the imported image files.
28+
29+
## Experimental OCI bundles
30+
31+
The normal image API accepts a `darwin/arm64` OCI manifest whose configuration
32+
labels describe a complete installed machine:
33+
34+
| Label | Value |
35+
|---|---|
36+
| `io.hypeman.machine-image.version` | `1` |
37+
| `io.hypeman.machine-image.kind` | `macos-image` |
38+
| `io.hypeman.machine-image.disk-format` | `raw` |
39+
| `io.hypeman.machine-image.disk-path` | Relative installed boot disk path |
40+
| `io.hypeman.machine-image.aux-path` | Relative auxiliary storage path |
41+
| `io.hypeman.machine-image.platform-path` | Relative macOS platform configuration JSON path |
42+
43+
All three files must be distinct, nonempty regular files inside the unpacked
44+
image. Platform configuration uses the local bundle's `MacOSImage` schema and
45+
is limited to 64 KiB. Absolute paths, traversal and symlink escapes are rejected.
46+
The installed disk is materialized directly rather than converted to a Linux
47+
root filesystem. The matching auxiliary storage and platform identity are
48+
retained. Do not package live mutable guest storage.
49+
50+
This schema is experimental pending reconciliation with other machine-image
51+
platforms. Complete bundles only: no base/delta chain, identity rebinding, or
52+
concurrent-template guarantee. Ordinary OCI transport does not imply sparse
53+
distribution efficiency, boot portability, or container execution semantics.
2654

2755
## API
2856

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package images
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/kernel/hypeman/lib/paths"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestFinalizationRollbackPreservesUninstalledManifest(t *testing.T) {
13+
p := paths.New(t.TempDir())
14+
m := &manager{paths: p}
15+
ref, err := ParseNormalizedRef("localhost/qa@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
16+
require.NoError(t, err)
17+
resolved := NewResolvedRef(ref, ref.Digest())
18+
require.NoError(t, writeMetadata(p, ref.Repository(), ref.DigestHex(), &imageMetadata{BuildID: "qa-build", Status: StatusPending}))
19+
layout := resolveImageLayout(p, ref.Repository(), ref.DigestHex())
20+
modelPath := manifestModelPath(p, layout, ref.DigestHex())
21+
require.NoError(t, os.MkdirAll(filepath.Dir(modelPath), 0700))
22+
require.NoError(t, os.WriteFile(modelPath, []byte("preexisting-manifest"), 0600))
23+
stagedDisk := filepath.Join(layout.dir, "qa-staged-disk")
24+
require.NoError(t, os.WriteFile(stagedDisk, []byte("synthetic-disk"), 0600))
25+
// Manifest validation fails before any model file is installed.
26+
err = m.finalizeImage(resolved, &pullResult{Metadata: &containerMetadata{OS: "linux", Architecture: "arm64"}, Manifest: &imageManifestModel{}}, "qa-build", stagedImageFiles{disk: stagedDisk, sizeBytes: 14})
27+
require.ErrorContains(t, err, "write manifest model")
28+
data, err := os.ReadFile(modelPath)
29+
require.NoError(t, err, "rollback must not remove a manifest that this attempt did not install")
30+
require.Equal(t, "preexisting-manifest", string(data))
31+
}

‎lib/images/macos_import_darwin_test.go‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ func TestMacOSOfflineImport(t *testing.T) {
4040
_, err = ImportMacOSImage(ctx, p, "localhost/macos:cancelled", source)
4141
require.Error(t, err)
4242
}
43-
func TestMacOSPlatformLocalOnly(t *testing.T) {
43+
func TestMacOSPlatform(t *testing.T) {
4444
p, err := ParsePlatform("darwin/arm64")
4545
require.NoError(t, err)
4646
require.Equal(t, "darwin", p.OS)
4747
_, err = ParsePlatform("darwin/amd64")
4848
require.Error(t, err)
49-
_, err = resolveManifestPlatform(&containerMetadata{OS: "darwin", Architecture: "arm64"}, "")
50-
require.ErrorIs(t, err, ErrInvalidPlatform)
49+
manifest, err := resolveManifestPlatform(&containerMetadata{OS: "darwin", Architecture: "arm64"}, "")
50+
require.NoError(t, err)
51+
require.Equal(t, "darwin/arm64", manifest.String())
5152
}

‎lib/images/macos_machine.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package images
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/kernel/hypeman/lib/forkvm"
9+
)
10+
11+
const (
12+
MacOSMachineVersionLabel = "io.hypeman.machine-image.version"
13+
MacOSMachineKindLabel = "io.hypeman.machine-image.kind"
14+
MacOSMachineDiskLabel = "io.hypeman.machine-image.disk-path"
15+
MacOSMachineFormatLabel = "io.hypeman.machine-image.disk-format"
16+
MacOSMachineAuxLabel = "io.hypeman.machine-image.aux-path"
17+
MacOSMachinePlatformLabel = "io.hypeman.machine-image.platform-path"
18+
)
19+
20+
// stageMacOSMachine copies a validated bundle's boot disk and auxiliary storage to
21+
// build-private paths, outside the manager lock. It returns the bytes both files
22+
// occupy, which is the size recorded for accounting.
23+
func stageMacOSMachine(payload *macOSMachinePayload, diskTemp, auxTemp string) (stagedImageFiles, error) {
24+
diskSize, err := stageMachineFile(payload.Disk, diskTemp)
25+
if err != nil {
26+
return stagedImageFiles{}, fmt.Errorf("stage boot disk: %w", err)
27+
}
28+
auxSize, err := stageMachineFile(payload.Aux, auxTemp)
29+
if err != nil {
30+
return stagedImageFiles{}, fmt.Errorf("stage auxiliary storage: %w", err)
31+
}
32+
return stagedImageFiles{disk: diskTemp, aux: auxTemp, macos: payload.Platform, sizeBytes: diskSize + auxSize}, nil
33+
}
34+
35+
func stageMachineFile(src, dst string) (int64, error) {
36+
// Copy helpers create the destination with the registry-supplied source mode,
37+
// and a multi-GB copy would expose contents before any later chmod. A fresh
38+
// 0700 directory blocks other local users during the copy; the final file is
39+
// then made 0600 before it appears at the canonical staging path.
40+
private, err := os.MkdirTemp(filepath.Dir(dst), ".machine-stage-")
41+
if err != nil {
42+
return 0, err
43+
}
44+
defer os.RemoveAll(private)
45+
staged := filepath.Join(private, filepath.Base(dst))
46+
if err := forkvm.CopyRegularFile(src, staged); err != nil {
47+
return 0, err
48+
}
49+
if err := os.Chmod(staged, 0600); err != nil {
50+
return 0, err
51+
}
52+
if err := os.Rename(staged, dst); err != nil {
53+
return 0, err
54+
}
55+
info, err := os.Stat(dst)
56+
if err != nil {
57+
return 0, err
58+
}
59+
return info.Size(), nil
60+
}
61+
62+
// checkMacOSMachineMetadata validates darwin labels from the config blob, so
63+
// malformed artifacts fail before layer unpacking. Non-darwin returns false.
64+
func checkMacOSMachineMetadata(meta *containerMetadata) (bool, error) {
65+
// Normalize as resolveManifestPlatform does, so aliases such as aarch64 and
66+
// case variants such as Darwin select the machine path rather than rootfs.
67+
normalized := Platform{OS: meta.OS, Architecture: meta.Architecture, Variant: meta.Variant}.Normalize()
68+
if normalized.OS != "darwin" {
69+
return false, nil
70+
}
71+
if normalized.Architecture != "arm64" || normalized.Variant != "" {
72+
return true, fmt.Errorf("macOS machine requires darwin/arm64")
73+
}
74+
labels := meta.Labels
75+
if labels[MacOSMachineVersionLabel] != "1" || labels[MacOSMachineKindLabel] != "macos-image" || labels[MacOSMachineFormatLabel] != "raw" {
76+
return true, fmt.Errorf("darwin artifact requires version 1 macos-image with raw disk, not an ordinary container image")
77+
}
78+
return true, nil
79+
}
80+
81+
func parseMacOSMachine(root string, meta *containerMetadata) (*macOSMachinePayload, error) {
82+
darwin, err := checkMacOSMachineMetadata(meta)
83+
if err != nil || !darwin {
84+
return nil, err
85+
}
86+
labels := meta.Labels
87+
return readMacOSMachineBundle(root, labels[MacOSMachineDiskLabel], labels[MacOSMachineAuxLabel], labels[MacOSMachinePlatformLabel], false)
88+
}

0 commit comments

Comments
 (0)