Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
04269fa
fix(gateway): refuse to unlink a non-socket at the socket path
pmoust Oct 13, 2026
a0df360
fix(gateway): close the netstack when the gateway stops
pmoust Oct 20, 2026
cafea4a
style: gofmt internal/bootassets/verify.go
pmoust Oct 15, 2026
e79f06c
refactor(cmd): move the macOS-only helpers into _darwin files
pmoust Oct 13, 2026
4695a3b
feat(linux): build cmd/hull for Linux and run hvi with a block rootfs
pmoust Oct 13, 2026
71d3da2
feat(ociclient): keep image ownership on unprivileged Linux unpacks
pmoust Oct 13, 2026
01ed488
fix(store): remove image trees that hold read-only directories
pmoust Oct 13, 2026
efd87a7
feat(hvi): pause, resume and stop hvi over a control socket
pmoust Oct 14, 2026
e5b0e52
feat(hvi): checkpoint hvi instances and restore a fenced checkpoint
pmoust Oct 14, 2026
17280e3
feat(hvi): move a running hvi instance to another host
pmoust Oct 14, 2026
d52b752
feat(hvi): keep a ledger of committed moves and confirm every abort
pmoust Oct 14, 2026
7eceff3
feat(hvi): run and move instances under the portable machine profile
pmoust Oct 14, 2026
59ee501
feat(hvi): keep a run's disk base by content digest
pmoust Oct 14, 2026
55a970d
feat(hvi): prepare a destination ahead of a move
pmoust Oct 14, 2026
61a285f
feat(hvi): restore an hvi instance from a stream
pmoust Oct 14, 2026
b6b8b0f
feat(hvi): move an instance as a stream with pre-copy rounds
pmoust Oct 14, 2026
a8cfb19
feat(hvi): run a move's steps over one ssh connection
pmoust Oct 14, 2026
720e5b7
fix(hvi): leave a destination's own profile kernel in place
pmoust Oct 14, 2026
2e6a94f
feat(hvi): build one base per image and remove bases nothing needs
pmoust Oct 15, 2026
44ad594
feat(hvi): keep a spare copy of a base for the next move to take
pmoust Oct 15, 2026
266579d
feat(hvi): carry the disk rounds inside hvi's migrate stream
pmoust Oct 15, 2026
211db1f
feat(hvi): recover a fenced guest through its fence owner's restream
pmoust Oct 15, 2026
0cf6b8f
feat(hvi): record both hosts' steps and count downtime to the resume
pmoust Oct 15, 2026
bda06f9
feat(hvi): hold a streamed move in one session with an ack window
pmoust Oct 15, 2026
64badeb
fix(hvi): say what --status knows of a streamed move's guest
pmoust Oct 15, 2026
73bea56
feat(hvi): make and check the base's spare for the way back
pmoust Oct 15, 2026
3a045c0
fix(hvi): bound hvi's pause waits and read its stream close as failure
pmoust Oct 15, 2026
2aea728
fix(migrate): cut off a destination that stalls a streamed move
pmoust Oct 16, 2026
8d7112a
fix(migrate): record the fence without racing the transfer's stats
pmoust Oct 16, 2026
c83a117
feat(migrate): replace a destination's kernel with the pinned one
pmoust Oct 16, 2026
9d3dfe3
test(migrate): answer the probe without a hypervisor on linux
pmoust Oct 18, 2026
a804352
test(migrate): wait for a killed hull's move lock to go
pmoust Oct 18, 2026
e2a967f
build(hvi): pin hvi-vmm to da59b45
pmoust Oct 15, 2026
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
38 changes: 34 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,43 @@ jobs:
- name: Check the cask renderer
run: python3 scripts/render-cask-test.py

linux:
name: linux (build, vet, lint, unit)
# hull builds for arm64 Linux as the hvi runtime on KVM (docs/backends.md,
# "hvi on Linux"). Nothing else in this file compiles it for Linux: lint
# above type-checks GOOS=darwin and every test lane is a Mac. A darwin-only
# symbol reaching a shared file would break the Linux binary with every
# gate green -- which is how cmd/hull stopped compiling for Linux in the
# first place. The unit tests need no /dev/kvm; no guest boots here.
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod

- name: Build
run: go build -o /dev/null ./cmd/hull

- name: Vet
run: go vet ./...

- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
with:
version: v2.12.2

# -p 2 for the same reason as everywhere else in this file. -count=1 so
# a cached result cannot stand in for a run.
- name: Unit tests
run: go test -count=1 -p 2 -timeout=10m ./...

telemetry:
name: telemetry module (linux)
# pkg/telemetry is a module of its own, so `go test ./...` and the lint
# above, both run from the root module, never reach it. It is also the
# one part of this repository that builds for Linux, because brig links
# it there: this job is where that is checked, natively rather than with
# GOOS set.
# above, both run from the root module, never reach it. brig links it on
# Linux, so this job checks it there as its own module, natively rather
# than with GOOS set.
runs-on: ubuntu-latest
defaults:
run:
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ BUILD_DIR ?= ${CURDIR}/dist
#? PREFIX Directory to install binaries (default: /usr/local/bin)
PREFIX ?= /usr/local/bin

# Architecture (Apple Silicon only for now)
# Architecture. arm64 is the only one built or tested: Apple Silicon for the
# macOS targets, and arm64 Linux/KVM for `make linux`.
ARCH ?= arm64

# Binary variables
Expand Down Expand Up @@ -101,6 +102,13 @@ urunc_macos:
GOARCH=$(ARCH) $(GO) build -ldflags "$(LDFLAGS)" \
-o $(HULL_BIN)_$(ARCH) $(CURDIR)/cmd/hull

## linux Build the hull CLI for Linux (GOOS=linux, GOARCH=$(ARCH)); build hvi on the Linux host itself.
.PHONY: linux
linux:
mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=$(ARCH) $(GO) build -ldflags "$(LDFLAGS)" \
-o $(HULL_BIN)_linux_$(ARCH) $(CURDIR)/cmd/hull

## vz_runner Build the Swift Virtualization.framework runner.
.PHONY: vz_runner
vz_runner:
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Kubernetes integration, and it implements a subset of Compose. See
`vz-runner` declares a macOS 14 floor in `vz-runner/Package.swift`, so
earlier versions can work. Nothing blocks them, and nothing tests them.
- About 2 GB of free disk space for the store, plus each image you pull.
- On Linux, hull builds from source for arm64 with KVM and runs only the
`hvi` backend with a block rootfs. A sandbox cannot be moved there from a
Mac. See [docs/backends.md](docs/backends.md#hvi-on-linux) and
[docs/build.md](docs/build.md#linux).

## Install

Expand Down Expand Up @@ -121,7 +125,8 @@ Three backends exist. They are not equivalent, and the gaps are real:
| `--net shared` | NAT, works with no extra privilege | **refused**, see below | vmnet, needs root or a QEMU signed for `com.apple.vm.networking` |
| `--gateway-sock` | yes | yes | yes |
| `exec` | yes | yes | yes |
| Checkpoint and restore | yes, with a block rootfs | no | no |
| Checkpoint and restore | yes, with a block rootfs | checkpoint with a block rootfs and no shares, into a partial bundle; no restore yet ([why](docs/checkpoint-restore.md#what-a-bundle-holds)) | no |
| Pause and resume | no | yes | no |
| GUI window, Rosetta | yes | no | no |

**The `hvi` networking gap is the one to know.** hvi's built-in stack answers
Expand Down Expand Up @@ -160,8 +165,9 @@ instance. [docs/architecture.md](docs/architecture.md) explains the seams.
with a warning on stderr. A clean `compose config` does not mean every key in
the file is honored at runtime. [docs/compose.md](docs/compose.md) lists what
works.
- Checkpoint and restore are `vz` only, need a block rootfs, and make no
promise about application-level consistency. They do not move a VM to another
- Checkpoint and restore work on `vz`. `hvi` checkpoints into a bundle that
cannot be resumed yet, and its restore waits for `hvi`'s. Both need a block
rootfs, and they make no promise about application-level consistency. They do not move a VM to another
host, and they cannot undo an effect the guest already sent to the outside
world. See [docs/checkpoint-restore.md](docs/checkpoint-restore.md).
- The `vz` and `hvi` backends need a code-signing entitlement. The Homebrew
Expand Down
49 changes: 49 additions & 0 deletions cmd/hull/apfs_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2026, NOFire AI
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"fmt"

"golang.org/x/sys/unix"
)

// requireSameAPFSVolume refuses an hvi writable rootfs unless the image store
// and the instance directory are one APFS volume, which is what makes the
// per-instance clone in cloneRootfsAPFS a clone rather than a full copy.
func requireSameAPFSVolume(source, destinationParent string) error {
var sourceFS, destinationFS unix.Statfs_t
if err := unix.Statfs(source, &sourceFS); err != nil {
return fmt.Errorf("inspect source filesystem for APFS clone: %w", err)
}
if err := unix.Statfs(destinationParent, &destinationFS); err != nil {
return fmt.Errorf("inspect destination filesystem for APFS clone: %w", err)
}
fsName := func(raw [16]byte) string {
for i, value := range raw {
if value == 0 {
return string(raw[:i])
}
}
return string(raw[:])
}
if fsName(sourceFS.Fstypename) != "apfs" || fsName(destinationFS.Fstypename) != "apfs" {
return fmt.Errorf("HVI writable rootfs requires an APFS image store and instance directory")
}
if sourceFS.Fsid.Val != destinationFS.Fsid.Val {
return fmt.Errorf("HVI writable rootfs requires the image store and instance directory on the same APFS volume")
}
return nil
}
28 changes: 28 additions & 0 deletions cmd/hull/apfs_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) 2026, NOFire AI
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import "errors"

// requireSameAPFSVolume always refuses on Linux. The hvi writable-directory
// rootfs is an APFS clone served over virtio-fs, and Linux has neither: no
// APFS, and no virtio-fs server in hvi's KVM backend. checkLinuxRun refuses
// that mode before anything reaches here; this is the backstop, so that a
// path which skipped the preflight fails instead of falling back to a full
// copy of the image.
func requireSameAPFSVolume(_, _ string) error {
return errors.New("the hvi writable-directory rootfs needs APFS and virtio-fs; " +
"on Linux use --rootfs-type block")
}
139 changes: 135 additions & 4 deletions cmd/hull/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build darwin

package main

import (
Expand Down Expand Up @@ -42,7 +40,15 @@ func assetsCommand() *cli.Command {
{
Name: "show",
Usage: "show where the boot assets are and whether they are present",
Flags: []cli.Flag{assetsProfileFlag()},
Action: func(_ context.Context, cmd *cli.Command) error {
profile, err := bootassets.NormalizeProfile(cmd.String("profile"))
if err != nil {
return err
}
if profile != "" {
return showProfileAssets(cmd.String("store-dir"), profile)
}
return showBootAssets(cmd.String("store-dir"))
},
},
Expand All @@ -59,8 +65,12 @@ func assetsCommand() *cli.Command {
//
// One bare line, no decoration, so a shell can consume it:
// assets=$(hull assets dir)
//
// With --profile it is that profile's kernel directory
// instead; the initrd a profile boots stays in the plain one.
Flags: []cli.Flag{assetsProfileFlag()},
Action: func(_ context.Context, cmd *cli.Command) error {
dir, err := bootassets.Dir(cmd.String("store-dir"))
dir, err := assetsDir(cmd.String("store-dir"), cmd.String("profile"))
if err != nil {
return err
}
Expand All @@ -70,15 +80,23 @@ func assetsCommand() *cli.Command {
},
{
Name: "pull",
Usage: "download the boot assets for this host",
Usage: "download the boot assets for this host, or install a profile's kernel",
ArgsUsage: "[REF]",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "force",
Usage: "download even when the assets are already present",
},
assetsProfileFlag(),
},
Action: func(ctx context.Context, cmd *cli.Command) error {
profile, err := bootassets.NormalizeProfile(cmd.String("profile"))
if err != nil {
return err
}
if profile != "" {
return pullProfileAssets(ctx, cmd, profile)
}
ref := bootassets.Ref()
if cmd.Args().Len() > 0 {
ref = cmd.Args().First()
Expand Down Expand Up @@ -115,6 +133,119 @@ func assetsCommand() *cli.Command {
}
}

// assetsProfileFlag selects a machine profile whose kernel is a variant of
// the bundle (see internal/bootassets/profile.go).
func assetsProfileFlag() cli.Flag {
return &cli.StringFlag{
Name: "profile",
Usage: "the machine profile whose kernel to act on: " + bootassets.PortableArm64V1 +
" (default: native, the bundle itself)",
}
}

// assetsDir is the directory `hull assets dir` prints.
func assetsDir(storeDir, name string) (string, error) {
profile, err := bootassets.NormalizeProfile(name)
if err != nil {
return "", err
}
if profile == "" {
return bootassets.Dir(storeDir)
}
return bootassets.ProfileDir(storeDir, profile)
}

// pullProfileAssets installs a profile's kernel: from REF when one is given,
// otherwise from HULL_PORTABLE_KERNEL or HULL_PORTABLE_KERNEL_REF. The
// default bundle is fetched too when it is missing, since the profile boots
// its initrd.
func pullProfileAssets(ctx context.Context, cmd *cli.Command, profile string) error {
storeDir := cmd.String("store-dir")
if _, err := bootassets.ProfileDir(storeDir, profile); err != nil {
return err
}
if err := ensureStore(storeDir, defaultStoreDir()); err != nil {
return err
}
if !bootassets.Present(storeDir) {
if err := bootassets.Fetch(ctx, bootassets.Ref(), storeDir); err != nil {
return err
}
}
if !cmd.Bool("force") && bootassets.ProfileKernelPresent(storeDir, profile) {
dir, err := bootassets.ProfileDir(storeDir, profile)
if err != nil {
return err
}
// Present is not the same as intact: a kernel that no longer
// matches its record would otherwise be reported as installed,
// and the run that boots it refused.
kernel, _, err := bootassets.ProfilePaths(storeDir, profile)
if err != nil {
return err
}
if verr := bootassets.VerifyFiles(kernel); verr != nil && !errors.Is(verr, bootassets.ErrNoProvenance) {
return verr
}
fmt.Printf("the %s kernel is already present in %s (use --force to replace it)\n", profile, dir)
return nil
}
var err error
if cmd.Args().Len() > 0 {
err = bootassets.FetchProfile(ctx, cmd.Args().First(), storeDir, profile)
} else {
err = bootassets.InstallProfile(ctx, storeDir, profile)
}
if err != nil {
return err
}
return showProfileAssets(storeDir, profile)
}

// showProfileAssets is `hull assets show --profile`: the profile's kernel,
// where it came from, and the default initrd it boots with.
func showProfileAssets(storeDir, profile string) error {
dir, err := bootassets.ProfileDir(storeDir, profile)
if err != nil {
return err
}
kernel, initrd, err := bootassets.ProfilePaths(storeDir, profile)
if err != nil {
return err
}
fmt.Printf("profile: %s\ndirectory: %s\n", profile, dir)
switch record, err := bootassets.ProfileRecord(storeDir, profile); {
case errors.Is(err, bootassets.ErrNoProvenance):
fmt.Printf("provenance: none\n")
case err != nil:
fmt.Printf("provenance: unreadable: %v\n", err)
case record.VerifiedDigest != "":
fmt.Printf("installed from: %s (%s)\nsignature: verified against %s\n", record.Ref,
record.Digest, record.VerifiedDigest)
default:
fmt.Printf("installed from: %s (%s)\nsignature: none (a local file, or nothing was checked)\n",
record.Ref, record.Digest)
}
fmt.Println()
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
row := func(format string, args ...any) {
_, _ = fmt.Fprintf(w, format, args...)
}
row("FILE\tSIZE\tSTATUS\n")
for _, path := range []string{kernel, initrd} {
info, statErr := os.Stat(path)
switch {
case statErr != nil:
row("%s\t-\tmissing\n", path)
case info.Size() == 0:
row("%s\t0\tempty\n", path)
default:
row("%s\t%d\tok\n", path, info.Size())
}
}
return w.Flush()
}

func showBootAssets(storeDir string) error {
dir, err := bootassets.Dir(storeDir)
if err != nil {
Expand Down
Loading
Loading