a73x

internal/agent/vfkit/vfkit.go

Ref:   Size: 19.8 KiB   History

// Package vfkit manages one vfkit process per VM: the macOS backend, where
// vfkit is the signed helper that drives Apple's Virtualization.framework.
// It is cloudhv's opposite number and deliberately its mirror image —
// argument assembly, a pidfile, a graceful stop over a unix socket with a
// SIGTERM fallback — because the two backends answer the same seam and a
// reader who knows one should recognize the other.
//
// vfkit rather than a helper of our own: Virtualization.framework is reachable
// only from Objective-C or Swift, so SOMETHING has to stand between the agent
// and the framework. vfkit is that binary already written, Apache-2.0, signed
// with com.apple.security.virtualization, and shipped through Homebrew — and
// it takes the shape the agent already spawns, one process per VM that outlives
// its parent. Writing our own would buy a second thing to sign and notarize.
//
// The package carries no build tag, so Linux CI proves its behavior even
// though only wire_darwin.go builds it into a binary. Nothing here is
// Darwin-only in Go terms: the platform lives in the strings (vfkit's
// arguments, the lease database's path), not in the syscalls.
package vfkit

import (
	"bytes"
	"context"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"

	agentexec "github.com/a73x/eitri/internal/agent/exec"
	"github.com/a73x/eitri/internal/agent/hostinfo"
	"github.com/a73x/eitri/internal/agent/hyperlog"
	"github.com/a73x/eitri/internal/agent/permanent"
	"github.com/a73x/eitri/internal/agent/pidfile"
	"github.com/a73x/eitri/internal/agent/state"
)

// logMode is the file mode for vfkit's diagnostic log.
const logMode = 0o600

// PumpHooks is the serial-console pump lifecycle the provisioner drives
// (consumer-owned; the concrete implementation is *serialpump.Manager, wired
// by the composition root — vfkit must not import serialpump).
type PumpHooks interface {
	Ensure(vmID string)
	Stop(vmID string)
}

// Provisioner manages vfkit processes for all VMs on this host.
//
// There is no Network seam here, and its absence is the design. On Linux the
// agent owns addressing outright — it builds the bridge, creates a tap per
// guest and answers DHCP itself — so cloudhv needs a collaborator to do that
// per VM. macOS keeps that machinery for itself: vmnet's NAT and its bootpd
// hand a guest its address once the guest asks, and nothing the agent can
// install changes the answer. So there is no per-VM host networking to
// lifecycle, only a lease to read (see Address).
type Provisioner struct {
	st  *state.Store
	bin string // vfkit binary: a path, or a bare name resolved on $PATH
	run agentexec.Runner

	lookPath func(file string) (string, error)

	leasesPath string

	// bootID identifies the host boot a VM's process was started in. It is the
	// same reader reconcile's own boot-ID guard uses, so the two agree by
	// construction. Injected so a test can move the host to a later boot
	// without rebooting the box running it.
	bootID func() string

	signal func(pid int, sig syscall.Signal) error

	// rest is the one client every vfkit REST call on this host goes through.
	// One, deliberately: see newRESTClient.
	rest *http.Client

	Pumps PumpHooks
}

// defaultLeasesPath is where macOS's bootpd records the addresses it has
// handed out. It is the whole of this backend's address knowledge.
const defaultLeasesPath = "/var/db/dhcpd_leases"

// New constructs a Provisioner. run may be nil when only pure methods
// (buildArgs) are needed.
func New(st *state.Store, bin string, run agentexec.Runner) *Provisioner {
	return &Provisioner{
		st:         st,
		bin:        bin,
		run:        run,
		lookPath:   exec.LookPath,
		leasesPath: defaultLeasesPath,
		bootID:     hostinfo.BootID,
		signal:     syscall.Kill,
		rest:       newRESTClient(),
	}
}

// Preflight refuses a host that has no vfkit. The one thing this backend needs
// beyond itself cannot be installed by the agent the way cloud-hypervisor can:
// vfkit must carry Apple's virtualization entitlement, and an entitlement
// survives only a signature we cannot produce, so a downloaded copy is not a
// working copy. Homebrew's is signed; that is the install path, and naming it
// here is the whole point of answering before the image fetch rather than
// after it.
//
// The refusal is permanent because the retry budget cannot fix it: three
// attempts against a Mac with no vfkit produce the same sentence three times.
// Installing vfkit does not revive the failed VM — create it again.
// It also refuses a state directory too deep to hold a VM's control socket.
// That is a property of the host's configuration, not of any one VM, and it is
// checked here for the same reason: the alternative is discovering it from
// vfkit, after the image download, in a sentence about URIs.
func (p *Provisioner) Preflight(_ context.Context) error {
	if _, err := p.lookPath(p.bin); err != nil {
		return permanent.Errorf("vfkit not found on this host (looked for %q): install it with `brew install vfkit` — "+
			"the macOS backend runs guests through it, and it must be Apple-entitled, so the agent cannot install it itself", p.bin)
	}
	if sock := p.sockPath(strings.Repeat("0", vmIDLen)); len(sock) > maxSocketPath {
		return permanent.Errorf("state directory is too deep for macOS: a VM's control socket would be %q, %d bytes against the %d-byte limit — "+
			"run the agent with a shorter --state-dir", sock, len(sock), maxSocketPath)
	}
	return nil
}

const (
	// maxSocketPath is macOS's cap on a unix socket path: sockaddr_un.sun_path
	// is 104 bytes and the last one is the terminator. Linux allows 108, so a
	// path that works in a test on Linux can still be refused on the platform
	// this backend runs on — which is why it is checked rather than assumed.
	maxSocketPath = 103

	// vmIDLen is how long a VM id is (32 hex characters, server-minted).
	// Preflight has to measure a VM's socket path before there is a VM, so it
	// measures one of the right shape.
	vmIDLen = 32
)

// vmFile returns a path inside the VM's directory. vfkit's per-VM artifacts
// are named here rather than in state.Store because they are this backend's
// business: the store holds what every backend has (the disk, the seed, the
// serial log), and a second backend's private files do not belong in a type
// both of them import.
func (p *Provisioner) vmFile(vmID, name string) string {
	return filepath.Join(p.st.VMDir(vmID), name)
}

func (p *Provisioner) pidPath(vmID string) string  { return p.vmFile(vmID, "vfkit.pid") }
func (p *Provisioner) sockPath(vmID string) string { return p.vmFile(vmID, "vfkit.sock") }
func (p *Provisioner) varStore(vmID string) string { return p.vmFile(vmID, "efi-vars.fd") }
func (p *Provisioner) logPath(vmID string) string  { return p.vmFile(vmID, "vfkit.log") }

// FailureReason quotes the last thing vfkit said. See
// reconcile.Provisioner.
func (p *Provisioner) FailureReason(vmID string) string { return hyperlog.Reason(p.logPath(vmID)) }

// SocketPath is the VM's vfkit REST socket, exported so the composition root
// can hand ConsoleSource the same path Boot writes.
func (p *Provisioner) SocketPath(vmID string) string { return p.sockPath(vmID) }

// disks returns the VM's block devices in attachment order: the root disk, the
// cloud-init seed, then every volume in spec order — so the first volume the
// tenant attached is /dev/vdc, and stays /dev/vdc across reboots. Every one of
// these files must already exist when vfkit starts — it opens them while
// parsing its arguments, before any VM is built — which they do: reconcile
// prepares the disk, writes the seed and materialises the volumes before Boot.
//
// vfkit's virtio-blk has no read-only option
// — unlike cloud-hypervisor's — so state.Disk.ReadOnly is dropped here rather
// than honored. Nothing rests on it: the seed is a per-VM file, and cloud-init
// mounts it read-only from inside the guest regardless.
func (p *Provisioner) disks(spec state.VMSpec) []state.Disk {
	out := []state.Disk{
		{Path: p.st.DiskPath(spec.VMID)},
		{Path: p.st.SeedPath(spec.VMID), ReadOnly: true},
	}
	for _, id := range spec.VolumeIDs {
		out = append(out, state.Disk{Path: p.st.VolumePath(id)})
	}
	return out
}

func (p *Provisioner) buildArgs(spec state.VMSpec, createVarStore bool) []string {
	vmID := spec.VMID

	// The EFI variable store is the guest's NVRAM, and `create` initializes a
	// fresh one — which is why it is conditional. Ubuntu writes its boot entry
	// there on first boot; recreating the store every launch would throw that
	// away each time and leave the guest booting only by the removable-media
	// fallback path. Create it exactly once, on the boot that has no store yet.
	bootloader := "efi,variable-store=" + p.varStore(vmID)
	if createVarStore {
		bootloader += ",create"
	}

	args := []string{
		// The REST socket is how Shutdown asks for an ACPI power-down and how
		// the console finds its PTY. Without it vfkit exposes neither.
		"--restful-uri", "unix://" + p.sockPath(vmID),
		"--cpus", strconv.FormatInt(spec.VCPUs, 10),
		"--memory", strconv.FormatInt(spec.MemMB, 10),
		"--bootloader", bootloader,
	}
	for _, d := range p.disks(spec) {
		args = append(args, "--device", "virtio-blk,path="+d.Path)
	}
	return append(args,
		// nat is Apple's vmnet-shared network: the host NATs the guest out and
		// its bootpd answers the guest's DHCP. mac is the stickiness key — it
		// is what makes the lease, and therefore the address, the same one
		// across a rebuild (see Address).
		"--device", "virtio-net,nat,mac="+state.MAC(vmID),
		// pty, not logFilePath: the console has to carry keystrokes back to the
		// guest, and a log file is one-way. The pump opens the far end.
		"--device", "virtio-serial,pty",
		// cloud-hypervisor gives a guest virtio-rng without being asked; vfkit
		// does not, and a guest short of entropy stalls in early boot.
		"--device", "virtio-rng",
	)
}

// maxDiskGB caps a VM disk at 1 PiB (2^20 GiB) — far beyond any real host,
// and small enough that DiskGB<<30 can never overflow int64 (2^50 max).
const maxDiskGB = 1 << 20

// PrepareRootDisk creates the VM's root disk by cloning basePath on APFS and
// growing it to spec.DiskGB gigabytes.
//
// The shrink guard is cloudhv's, restated rather than shared: truncating to an
// EXACT size means a target below the base image would chop the guest
// filesystem, and that is true of any backend that grows a cloned image. The
// duplication is deliberate for now — hoisting the policy into reconcile is
// the right fix, and is a change to the shared engine, not to this backend.
func (p *Provisioner) PrepareRootDisk(ctx context.Context, spec state.VMSpec, basePath string) error {
	base, err := os.Stat(basePath)
	if err != nil {
		return fmt.Errorf("stat base image %s: %w", basePath, err)
	}
	if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB {
		return permanent.Errorf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB))
	}
	targetBytes := spec.DiskGB << 30
	if targetBytes < base.Size() {
		return permanent.Errorf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest",
			spec.DiskGB, targetBytes, basePath, base.Size())
	}
	if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil {
		return fmt.Errorf("mkdir %s: %w", p.st.VMDir(spec.VMID), err)
	}
	// Build into a sibling temp file and rename into place so a create killed
	// mid-copy can never leave a torn disk.raw at the final path. Sibling, so
	// the clone stays on one filesystem and the rename is atomic.
	diskPath := p.st.DiskPath(spec.VMID)
	tmpPath := diskPath + ".partial"
	_ = os.Remove(tmpPath)
	if err := p.clone(ctx, basePath, tmpPath); err != nil {
		_ = os.Remove(tmpPath)
		return err
	}
	// Grown in-process rather than by truncate(1): macOS does not ship one.
	// os.Truncate grows sparsely, exactly as GNU truncate does on Linux.
	if err := os.Truncate(tmpPath, targetBytes); err != nil {
		_ = os.Remove(tmpPath)
		return fmt.Errorf("grow %s to %dG: %w", tmpPath, spec.DiskGB, err)
	}
	if err := os.Rename(tmpPath, diskPath); err != nil {
		_ = os.Remove(tmpPath)
		return fmt.Errorf("rename %s -> %s: %w", tmpPath, diskPath, err)
	}
	return nil
}

// clone copies src to dst, sharing blocks where the filesystem can. `cp -c`
// is APFS's clonefile — the reflink analog — but unlike `cp --reflink=auto` it
// FAILS rather than degrading when the filesystem cannot clone, so the fallback
// is spelled out here. A Mac's boot volume is APFS; a state directory on an
// external HFS+ disk is the case that needs the second attempt.
func (p *Provisioner) clone(ctx context.Context, src, dst string) error {
	if _, err := p.run(ctx, "cp", "-c", src, dst); err == nil {
		return nil
	}
	_ = os.Remove(dst) // a failed clone may have left a partial file in the way
	if _, err := p.run(ctx, "cp", src, dst); err != nil {
		return fmt.Errorf("cp %s %s: %w", src, dst, err)
	}
	return nil
}

// Boot spawns a vfkit process for spec. The process is placed in its own
// session (Setsid) so it survives an agent restart, and ctx is deliberately NOT
// wired to it: tying a guest's lifetime to the agent's would power off every VM
// on a graceful agent stop. Stopping a VM is Shutdown/Destroy's job alone.
func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) error {
	_ = os.Remove(p.sockPath(vmID))

	// Anything short of a successful stat means "initialise a store". Matching
	// ENOENT alone read every other stat failure — EACCES on a state dir whose
	// ownership moved, EIO on a sick disk — as proof the store is there, which
	// is the one thing a failed stat cannot establish. Contents are not judged
	// and cannot be: a truncated store stats perfectly well, and the firmware is
	// the only reader that would know. A VM whose NVRAM is unreadable is rebuilt
	// under a new id and a new directory, which is where it gets a fresh one.
	_, err := os.Stat(p.varStore(vmID))
	args := p.buildArgs(spec, err != nil)

	cmd := exec.Command(p.bin, args...)
	cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}

	// Guest console output goes to the PTY the pump drains. vfkit's own
	// diagnostics — startup errors, the framework's complaints — go here.
	vfLog, err := os.OpenFile(p.logPath(vmID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, logMode)
	if err != nil {
		return fmt.Errorf("open vfkit.log %s: %w", vmID, err)
	}
	cmd.Stdout = vfLog
	cmd.Stderr = vfLog

	if err := cmd.Start(); err != nil {
		_ = vfLog.Close()
		return fmt.Errorf("vfkit start %s: %w", vmID, err)
	}
	_ = vfLog.Close() // the child holds its own copy

	if err := pidfile.Write(p.pidPath(vmID), cmd.Process.Pid, p.bootID()); err != nil {
		// Best effort: kill the orphan we can no longer track, and Wait to reap
		// it — the async reaper below is not started on this path.
		_ = cmd.Process.Kill()
		_ = cmd.Wait()
		return fmt.Errorf("track vfkit %s: %w", vmID, err)
	}

	go func() { _ = cmd.Wait() }() // reap; exit status is not ours to judge

	// Start the pump now so the boot log lands in the ring from as close to
	// power-on as possible. Its first few Opens will fail — the PTY does not
	// exist until vfkit has built the VM — and the pump's reconnect loop is
	// exactly the retry for that.
	if p.Pumps != nil {
		p.Pumps.Ensure(vmID)
	}
	return nil
}

// ownedPID is the pid of the vfkit process THIS agent started for vmID, or 0
// when there is none it may signal. The rule — and the reason a pid alone is
// not evidence after a reboot — lives in internal/agent/pidfile, shared with
// the other backend so the two cannot disagree about whose process they are
// about to kill.
func (p *Provisioner) ownedPID(vmID string) int {
	return pidfile.Owned(p.pidPath(vmID), p.bootID())
}

// Running reports whether the vfkit process for vmID is still alive, by
// pidfile and signal 0.
//
// PID-liveness within this host boot: a pidfile from an earlier boot reports
// not-running outright (see ownedPID), and within one boot a pid this agent
// wrote cannot have been recycled while its process is alive. reconcile's
// boot-ID check remains the authoritative reboot guard for the VM's record.
func (p *Provisioner) Running(vmID string) bool {
	pid := p.ownedPID(vmID)
	if pid == 0 {
		return false
	}
	return p.signal(pid, 0) == nil
}

// Shutdown powers the guest off and takes its serial console with it: a
// powered-off guest has no console to replay, and a pump left running across a
// stop would hand its backlog — a dead boot — to the next viewer as if it were
// the live one. cloudhv holds the same invariant and states the reasoning in
// full. A shutdown that FAILED leaves the pump alone: the guest may still be
// running, and a running guest keeps its console.
func (p *Provisioner) Shutdown(ctx context.Context, vmID string) error {
	if err := p.powerOff(ctx, vmID); err != nil {
		return err
	}
	if p.Pumps != nil {
		p.Pumps.Stop(vmID)
	}
	return nil
}

// powerOff asks vfkit for a graceful stop — the framework's ACPI power-down,
// the same request cloud-hypervisor's power-button API makes. Falls back to
// SIGTERM when the socket is gone or refuses, which covers a vfkit that is
// unhealthy or a VM that is not running.
func (p *Provisioner) powerOff(ctx context.Context, vmID string) error {
	body := bytes.NewReader([]byte(`{"state":"Stop"}`))
	req, err := http.NewRequestWithContext(withSocket(ctx, p.sockPath(vmID)), http.MethodPost, "http://vfkit/vm/state", body)
	if err != nil {
		return p.sigterm(vmID)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := p.rest.Do(req)
	if err != nil {
		return p.sigterm(vmID)
	}
	resp.Body.Close()
	// vfkit answers 202 Accepted. Anything past 2xx means the stop did not
	// happen — a 400 for a VM in the wrong state, a 500 from the framework.
	if resp.StatusCode >= 300 {
		return p.sigterm(vmID)
	}
	return nil
}

// sigterm sends SIGTERM to the process identified by vmID's PID file.
func (p *Provisioner) sigterm(vmID string) error {
	pid := p.ownedPID(vmID)
	if pid == 0 {
		return nil
	}
	if err := p.signal(pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH {
		return fmt.Errorf("SIGTERM %s (pid %d): %w", vmID, pid, err)
	}
	return nil
}

// Destroy stops the VM and releases every host resource it holds: the vfkit
// process, its serial pump, its REST socket.
//
// The kill's error is propagated, exactly as cloudhv propagates the failure to
// delete a VM's tap, and for the same reason: nil is the promise reconcile
// deletes the record on the strength of, and the record is the only thing on
// this host that names the process. A SIGKILL the kernel refused has not proved
// the process gone, so answering nil would strand a live vfkit holding an
// unlinked disk, a vmnet attachment and a DHCP lease, with nothing left to find
// it by. A non-nil answer keeps the record and the next tick tries again.
//
// This backend has less to release than cloudhv does: it created no host
// networking, because vmnet's NAT is host-wide and outlives every guest. The
// guest's DHCP lease outlives it too, in a root-owned file bootpd ages out on
// its own; because the lease is keyed on the VM's deterministic MAC, a rebuilt
// VM reclaims the same address rather than leaking a new one.
func (p *Provisioner) Destroy(_ context.Context, vmID string) error {
	return p.kill(vmID)
}

// kill SIGKILLs the vfkit process for vmID, stops its serial pump and removes
// the pidfile and REST socket. It takes no context: killing a process by
// pidfile is a syscall, and pretending otherwise made callers think a cancelled
// context could skip a teardown.
func (p *Provisioner) kill(vmID string) error {
	// Pump teardown first: a pump leaked past a failed kill would reopen a PTY
	// whose far end is gone, forever.
	if p.Pumps != nil {
		p.Pumps.Stop(vmID)
	}
	pid := p.ownedPID(vmID)
	if pid != 0 {
		if err := p.signal(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
			return fmt.Errorf("SIGKILL %s (pid %d): %w", vmID, pid, err)
		}
	}
	_ = os.Remove(p.pidPath(vmID))
	_ = os.Remove(p.sockPath(vmID))
	return nil
}