Skip to content
Draft
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
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())
}
88 changes: 88 additions & 0 deletions lib/images/macos_machine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package images

import (
"fmt"
"os"
"path/filepath"

"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) {
// Copy helpers create the destination with the registry-supplied source mode,
// and a multi-GB copy would expose contents before any later chmod. A fresh
// 0700 directory blocks other local users during the copy; the final file is
// then made 0600 before it appears at the canonical staging path.
private, err := os.MkdirTemp(filepath.Dir(dst), ".machine-stage-")
if err != nil {
return 0, err
}
defer os.RemoveAll(private)
staged := filepath.Join(private, filepath.Base(dst))
if err := forkvm.CopyRegularFile(src, staged); err != nil {
return 0, err
}
if err := os.Chmod(staged, 0600); err != nil {
return 0, err
}
if err := os.Rename(staged, dst); err != nil {
return 0, err
}
info, err := os.Stat(dst)
if err != nil {
return 0, err
}
return info.Size(), nil
}

// checkMacOSMachineMetadata validates darwin labels from the config blob, so
// malformed artifacts fail before layer unpacking. Non-darwin returns false.
func checkMacOSMachineMetadata(meta *containerMetadata) (bool, 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 false, nil
}
if normalized.Architecture != "arm64" || normalized.Variant != "" {
return true, fmt.Errorf("macOS machine requires darwin/arm64")
}
labels := meta.Labels
if labels[MacOSMachineVersionLabel] != "1" || labels[MacOSMachineKindLabel] != "macos-image" || labels[MacOSMachineFormatLabel] != "raw" {
return true, fmt.Errorf("darwin artifact requires version 1 macos-image with raw disk, not an ordinary container image")
}
return true, nil
}

func parseMacOSMachine(root string, meta *containerMetadata) (*macOSMachinePayload, error) {
darwin, err := checkMacOSMachineMetadata(meta)
if err != nil || !darwin {
return nil, err
}
labels := meta.Labels
return readMacOSMachineBundle(root, labels[MacOSMachineDiskLabel], labels[MacOSMachineAuxLabel], labels[MacOSMachinePlatformLabel], false)
}
Loading
Loading