Skip to content
34 changes: 31 additions & 3 deletions docs/macos-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,37 @@ go run -tags containers_image_openpgp ./cmd/import-macos \
--name localhost/macos:spike
```

The data directory needs APFS clonefile support. 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. 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. 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
31 changes: 31 additions & 0 deletions lib/images/finalization_rollback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package images

import (
"os"
"path/filepath"
"testing"

"github.com/kernel/hypeman/lib/paths"
"github.com/stretchr/testify/require"
)

func TestFinalizationRollbackPreservesUninstalledManifest(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))
stagedDisk := filepath.Join(layout.dir, "qa-staged-disk")
require.NoError(t, os.WriteFile(stagedDisk, []byte("synthetic-disk"), 0600))
// Manifest validation fails before any model file is installed.
err = m.finalizeImage(resolved, &pullResult{Metadata: &containerMetadata{OS: "linux", Architecture: "arm64"}, Manifest: &imageManifestModel{}}, "qa-build", stagedImageFiles{disk: stagedDisk, sizeBytes: 14})
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))
}
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())
}
64 changes: 64 additions & 0 deletions lib/images/macos_machine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package images

import (
"fmt"
"os"

"github.com/kernel/hypeman/lib/forkvm"
)

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"
)

// stageMacOSMachine copies a validated bundle's boot disk and auxiliary storage to
// build-private paths, outside the manager lock. It returns the bytes both files
// occupy, which is the size recorded for accounting.
func stageMacOSMachine(payload *macOSMachinePayload, diskTemp, auxTemp string) (stagedImageFiles, error) {
diskSize, err := stageMachineFile(payload.Disk, diskTemp)
if err != nil {
return stagedImageFiles{}, fmt.Errorf("stage boot disk: %w", err)
}
auxSize, err := stageMachineFile(payload.Aux, auxTemp)
if err != nil {
return stagedImageFiles{}, fmt.Errorf("stage auxiliary storage: %w", err)
}
return stagedImageFiles{disk: diskTemp, aux: auxTemp, macos: payload.Platform, sizeBytes: diskSize + auxSize}, nil
}

func stageMachineFile(src, dst string) (int64, error) {
if err := forkvm.CopyRegularFile(src, dst); err != nil {
return 0, err
}
// Registry-supplied modes must not expose the canonical files to other local users.
if err := os.Chmod(dst, 0600); err != nil {
return 0, err
}
info, err := os.Stat(dst)
if err != nil {
return 0, err
}
return info.Size(), nil
}

func parseMacOSMachine(root string, meta *containerMetadata) (*macOSMachinePayload, error) {
// Normalize as resolveManifestPlatform does, so aliases such as aarch64 and
// case variants such as Darwin select the machine path rather than rootfs.
normalized := Platform{OS: meta.OS, Architecture: meta.Architecture, Variant: meta.Variant}.Normalize()
if normalized.OS != "darwin" {
return nil, nil
}
if normalized.Architecture != "arm64" || normalized.Variant != "" {
return nil, fmt.Errorf("macOS machine requires darwin/arm64")
}
labels := meta.Labels
if labels[MacOSMachineVersionLabel] != "1" || labels[MacOSMachineKindLabel] != "macos-image" || labels[MacOSMachineFormatLabel] != "raw" {
return nil, fmt.Errorf("darwin artifact requires version 1 macos-image with raw disk, not an ordinary container image")
}
return readMacOSMachineBundle(root, labels[MacOSMachineDiskLabel], labels[MacOSMachineAuxLabel], labels[MacOSMachinePlatformLabel], false)
}
230 changes: 230 additions & 0 deletions lib/images/macos_machine_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package images

import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"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/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"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)
for f, data := range map[string][]byte{"config.json": b, "disk.img": []byte("not a real boot disk: synthetic OCI transport fixture"), "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",
}}
}

func TestMacOSMachineValidation(t *testing.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)
})
}
outside := filepath.Join(t.TempDir(), "outside")
require.NoError(t, os.WriteFile(outside, []byte("outside"), 0600))
require.NoError(t, os.Symlink(outside, filepath.Join(root, "escape")))
meta := macOSFixtureMetadata()
meta.Labels[MacOSMachineDiskLabel] = "escape"
_, err = parseMacOSMachine(root, meta)
require.Error(t, err)
meta = macOSFixtureMetadata()
meta.Architecture = "amd64"
_, 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)
}

// A hardlink with a different name is the same file and must not satisfy distinctness.
linked := macOSFixture(t)
require.NoError(t, os.Link(filepath.Join(linked, "disk.img"), filepath.Join(linked, "alias.img")))
meta = macOSFixtureMetadata()
meta.Labels[MacOSMachineAuxLabel] = "alias.img"
_, err = parseMacOSMachine(linked, meta)
require.ErrorContains(t, err, "distinct")
}

// Optional real-bundle run only reads a stopped source, never the live benchmark disk.
// It proves normal manager pull/materialization, not VZ boot or API server deployment.
func TestMacOSMachineOCIRoundTrip(t *testing.T) {
source := macOSFixture(t)
work := t.TempDir()
_, err := parseMacOSMachine(source, macOSFixtureMetadata())
require.NoError(t, err)
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{}
t.Log("packing stopped bundle", source)
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())
t.Log("packed; streaming blobs into loopback registry storage")
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)
require.NoError(t, os.MkdirAll(filepath.Join(work, "registry-blobs"), 0700))
// The test registry buffers PATCH uploads in memory even with a disk blob
// handler. Seed blobs through the streaming handler so real VM layers do
// not consume tens of GiB of RAM; remote.Write still publishes the manifest.
blobs := registry.NewDiskBlobHandler(filepath.Join(work, "registry-blobs"))
writer := blobs.(registry.BlobPutHandler)
layerDigest, err := layer.Digest()
require.NoError(t, err)
compressed, err := layer.Compressed()
require.NoError(t, err)
err = writer.Put(context.Background(), "macos", layerDigest, compressed)
closeErr := compressed.Close()
require.NoError(t, err)
require.NoError(t, closeErr)
configDigest, err := image.ConfigName()
require.NoError(t, err)
rawConfig, err := image.RawConfigFile()
require.NoError(t, err)
require.NoError(t, writer.Put(context.Background(), "macos", configDigest, io.NopCloser(bytes.NewReader(rawConfig))))
server := httptest.NewServer(registry.New(registry.WithBlobHandler(blobs)))
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))
t.Log("published; pulling through normal image manager")
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)
for f, path := range map[string]string{"disk.img": disk, "aux.img": filepath.Join(filepath.Dir(disk), "aux.img")} {
input, err := os.Open(path)
require.NoError(t, err)
h := sha256.New()
_, err = io.Copy(h, input)
require.NoError(t, err)
require.NoError(t, input.Close())
require.Equal(t, hashes[f], fmt.Sprintf("%x", h.Sum(nil)), f)
info, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), info.Mode().Perm(), f)
}
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.Error(t, err)
t.Log("round-trip verified", pulled.Digest)
}
Loading
Loading