internal/agent/cloudhv/cloudhv.go
Ref: Size: 22.0 KiB History
// Package cloudhv manages one cloud-hypervisor process per VM.
// It handles disk preparation (reflink copy + resize), argument assembly,
// process lifecycle (boot / running / shutdown / kill), and the
// cloud-hypervisor HTTP API over a Unix socket.
package cloudhv
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
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"
)
// chLogMode is the file mode for the cloud-hypervisor diagnostic log.
const chLogMode = 0o600
// PumpHooks is the serial-console pump lifecycle the provisioner drives
// (consumer-owned; the concrete implementation is *serialpump.Manager, wired
// by main — cloudhv must not import serialpump). nil disables the hooks.
type PumpHooks interface {
Ensure(vmID string)
Stop(vmID string)
}
// Network is the host networking this backend's guests sit on: a Linux bridge
// reached through a per-VM tap. Consumer-owned (R5) and satisfied by
// netenv.Net — declaring it here rather than importing netenv keeps the driver
// testable against a fake and keeps the package graph free of a
// cloudhv → netenv edge.
//
// Every method is per-VM. Host-wide setup (the bridge, NAT, the DHCP
// responder) is the composition root's job and never appears here: it happens
// once at agent start, not once per guest.
type Network interface {
// ReserveIP returns this VM's sticky address on the NAT underlay,
// recording its DHCP reservation. Every guest has one, whatever else its
// spec asks for.
ReserveIP(vmID string) (string, error)
// Address returns the NAT address already reserved for vmID, or "" if none.
// It never allocates.
Address(vmID string) string
// NetworkAddress returns the address the site's own DHCP server granted
// this VM's named-network NIC, or "" — for a guest with no such NIC, and
// for one whose guest has not finished asking yet. The host learns it by
// watching, so unlike Address it is not known at boot.
NetworkAddress(vmID string) string
// CreateTap creates the VM's tap on the NAT bridge and pins ip as its
// reservation. When network is non-empty it ALSO creates a second tap on
// that named network's bridge — additional, never instead. Idempotent.
CreateTap(ctx context.Context, vmID, ip, network string) error
// DeleteTap removes the reservation and both taps. Idempotent.
DeleteTap(ctx context.Context, vmID string) error
// TapName is the device name of the NAT NIC's tap — the first --net
// argument, and therefore the guest's eth0.
TapName(vmID string) string
// NetTapName is the device name of the named-network NIC's tap, used only
// by a VM whose spec asked for one.
NetTapName(vmID string) string
}
// Provisioner manages cloud-hypervisor processes for all VMs on this host.
type Provisioner struct {
st *state.Store
chBin string // path to cloud-hypervisor binary
firmware string // path to CLOUDHV.fd (UEFI firmware)
run agentexec.Runner
net Network
// 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 delivers a signal to a pid. Injected because the branch that
// matters — a kill the kernel refuses — cannot otherwise be reached without
// depending on what the box running the tests is allowed to signal.
signal func(pid int, sig syscall.Signal) error
// Pumps receives serial-pump lifecycle calls at Boot/Shutdown/kill — a VM's
// pump lives exactly as long as its guest is powered on. nil = no-op.
Pumps PumpHooks
}
// New constructs a Provisioner. run may be nil when only pure methods
// (buildArgs) are needed; net must not be, since a VM's network attachment is
// part of booting it.
func New(st *state.Store, chBin, firmware string, run agentexec.Runner, net Network) *Provisioner {
return &Provisioner{
st: st, chBin: chBin, firmware: firmware, run: run, net: net,
bootID: hostinfo.BootID,
signal: syscall.Kill,
}
}
// BootstrapDest maps a --ch-bin value to the filesystem path bootstrap may
// install the binary at. The two are different vocabularies: --ch-bin is
// usually a bare command name resolved on $PATH at launch, which names no
// install destination — writing to it literally would drop the binary in the
// agent's working directory and every launch would still miss it. A bare name
// resolves via $PATH when already installed (so bootstrap sees it and
// no-ops), else lands in /usr/local/bin, which the systemd unit's default
// $PATH includes. An explicit path is its own destination.
func BootstrapDest(chBin string) string {
if strings.ContainsRune(chBin, os.PathSeparator) {
return chBin
}
if p, err := exec.LookPath(chBin); err == nil {
return p
}
return filepath.Join("/usr/local/bin", chBin)
}
// disks returns the VM's block devices in attachment order: the root disk, the
// read-only 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.
// Volumes are writable: the guest owns what is on them.
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
}
// buildArgs returns the cloud-hypervisor command-line arguments for spec.
// The result is deterministic given the same spec so it can be unit-tested
// without spawning a process.
func (p *Provisioner) buildArgs(spec state.VMSpec) []string {
vmID := spec.VMID
tap := p.net.TapName(vmID)
mac := state.MAC(vmID)
args := []string{
"--api-socket", p.st.SocketPath(vmID),
"--kernel", p.firmware,
"--cpus", fmt.Sprintf("boot=%d", spec.VCPUs),
"--memory", fmt.Sprintf("size=%dM", spec.MemMB),
"--disk",
}
// image_type=raw is load-bearing, not decoration: autodetected raw makes
// CH DISABLE SECTOR 0 WRITES, so the first boot's growpart rewrites the
// partition table only in memory and the guest dies in initramfs at its
// first power cycle. Declared raw keeps the GPT writable. (Autodetection
// is also deprecated in CH v53.)
for _, d := range p.disks(spec) {
if d.ReadOnly {
args = append(args, fmt.Sprintf("path=%s,image_type=raw,readonly=on", d.Path))
continue
}
args = append(args, fmt.Sprintf("path=%s,image_type=raw", d.Path))
}
// NIC ORDER IS THE GUEST ABI. The NAT NIC is first and therefore eth0: it
// is the management fabric every guest has, the address the gate splices
// to, and what the seed's primary netplan stanza expects to find. A guest
// that also asked for a named network gets that NIC second — same MAC
// determinism, its own tap, its own DHCP server answering it.
args = append(args, "--net", fmt.Sprintf("tap=%s,mac=%s", tap, mac))
if spec.Network != "" {
args = append(args, "--net",
fmt.Sprintf("tap=%s,mac=%s", p.net.NetTapName(vmID), state.NetMAC(vmID)))
}
return append(args,
"--serial", fmt.Sprintf("socket=%s", p.st.SerialSocketPath(vmID)),
"--console", "off",
)
}
// 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
// Preflight passes unconditionally: this backend exists only on a host that
// runs guests, and the one thing it needs beyond itself — the
// cloud-hypervisor binary — is installed by the agent's bootstrap step at
// start, long before any create. Re-checking it per create would trade a
// clear startup failure for a per-VM one.
func (p *Provisioner) Preflight(_ context.Context) error { return nil }
// PrepareRootDisk creates the VM's root disk by making a reflink copy of
// basePath (instant on XFS/btrfs; silent full-copy fallback on ext4) and then
// truncating it to spec.DiskGB gigabytes.
//
// truncate -s sets an EXACT size, so a target smaller than the base image
// would silently chop the guest filesystem. PrepareRootDisk refuses to
// shrink: spec.DiskGB must be in [1, maxDiskGB] and cover the base image. The
// range check runs first so the byte computation is overflow-safe (a naive
// DiskGB<<30 wraps to a small positive value for e.g. 2^34+10, bypassing the
// guard). Pinned by TestPrepareRootDiskRefusesToShrinkBaseImage and
// TestPrepareRootDiskShrinkGuardEdgeCases.
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))
}
if targetBytes := spec.DiskGB << 30; 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())
}
diskPath := p.st.DiskPath(spec.VMID)
// Ensure VM directory exists.
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 that is
// killed (ctx cancel on agent stop, ENOSPC) mid-copy/truncate can never leave
// a torn disk.raw at the final path — the reconcile "exists" gate keys off
// rec.BootID, not disk presence, but an atomic artifact keeps the on-disk
// state honest for debugging and any future disk-aware code. The temp is a
// sibling (same dir/filesystem) so cp keeps its reflink and rename is atomic.
tmpPath := diskPath + ".partial"
_ = os.Remove(tmpPath) // clear any leftover from an earlier interrupted create
if _, err := p.run(ctx, "cp", "--reflink=auto", basePath, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("cp --reflink=auto %s %s: %w", basePath, tmpPath, err)
}
sizeArg := fmt.Sprintf("%dG", spec.DiskGB)
if _, err := p.run(ctx, "truncate", "-s", sizeArg, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("truncate -s %s %s: %w", sizeArg, tmpPath, err)
}
if err := os.Rename(tmpPath, diskPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("rename %s -> %s: %w", tmpPath, diskPath, err)
}
return nil
}
// pidPath returns the path to the PID file for vmID.
func (p *Provisioner) pidPath(vmID string) string {
return filepath.Join(p.st.VMDir(vmID), "ch.pid")
}
// Boot spawns a cloud-hypervisor process for spec. The process is placed in
// its own session (Setsid) so it survives an agent restart. A goroutine calls
// cmd.Wait to reap the child when it exits.
//
// The ctx parameter bounds the network attach only. It is deliberately NOT
// wired to the process: the VM's lifetime must not be tied to the agent's
// (exec.CommandContext SIGKILLs the child on ctx cancel, which would
// hard-power-off every VM on a graceful agent stop). Stopping a VM is
// exclusively the job of Shutdown/Destroy, driven by the reconcile loop. Pinned
// by TestBootedVMSurvivesCtxCancellation.
func (p *Provisioner) Boot(ctx context.Context, vmID string, spec state.VMSpec) error {
// Attach the network FIRST: cloud-hypervisor takes the tap as a launch
// argument, so the device has to exist before the process does. A failure
// here surfaces the networking layer's own message — letting the launch
// fail instead yields an illegible cloud-hypervisor error for the same
// root cause — and %w keeps a Permanent() marker (a tap name collision)
// unwrappable, so reconcile still terminal-fails it in one attempt.
if err := p.attachNet(ctx, spec); err != nil {
return fmt.Errorf("attach network %s: %w", vmID, err)
}
// Remove stale sockets from a previous run. CH does NOT unlink a
// pre-existing socket path before binding (it removes it only on clean
// exit), so after a CH crash, SIGKILL, or host reboot a stale socket
// would make the next boot fail to bind.
_ = os.Remove(p.st.SocketPath(vmID))
_ = os.Remove(p.st.SerialSocketPath(vmID))
args := p.buildArgs(spec)
cmd := exec.Command(p.chBin, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
// Guest console output (serial) goes to a unix socket (--serial socket=…)
// that the serialpump drains into serial.log.
// CH's own diagnostic output (startup errors, API logs) goes to ch.log.
chLog, err := os.OpenFile(p.logPath(vmID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, chLogMode)
if err != nil {
return fmt.Errorf("open ch.log %s: %w", vmID, err)
}
cmd.Stdout = chLog
cmd.Stderr = chLog
if err := cmd.Start(); err != nil {
_ = chLog.Close()
return fmt.Errorf("cloud-hypervisor start %s: %w", vmID, err)
}
// Close the log fd in the parent; the child has its own copy.
_ = chLog.Close()
// Record the process so Running/Shutdown/Destroy can find it later — with
// the host boot it belongs to, because a pid alone stops meaning anything
// the moment the host reboots (see ownedPID).
if err := pidfile.Write(p.pidPath(vmID), cmd.Process.Pid, p.bootID()); err != nil {
// Best effort — kill the orphan if we can't track it, and Wait to reap it
// (the async reaper below is not started on this path, so without Wait the
// killed child would linger as a zombie for the agent's whole lifetime).
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("track cloud-hypervisor %s: %w", vmID, err)
}
// Reap child asynchronously; ignore exit error (VM may be killed intentionally).
go func() { _ = cmd.Wait() }()
// Attach the serial pump NOW: the pump must be the socket's one client
// from as close to power-on as possible so the boot log lands in the
// ring/log (older CH drops unconsumed serial output entirely; current CH
// only buffers a bounded amount).
if p.Pumps != nil {
p.Pumps.Ensure(vmID)
}
return nil
}
// attachNet gives the VM its address and its tap, in that order — the tap
// carries the DHCP reservation, so the address has to be known first.
// Idempotent: both halves tolerate a re-run, which is what makes a Boot retry
// and a restart-after-host-reboot the same code path.
//
// It takes the whole spec rather than the id because whether the VM gets a
// second NIC is the spec's to say: the network name the control plane sent
// rides here and nowhere else. The reservation is unconditional either way —
// every guest is on the NAT underlay.
func (p *Provisioner) attachNet(ctx context.Context, spec state.VMSpec) error {
ip, err := p.net.ReserveIP(spec.VMID)
if err != nil {
return err
}
return p.net.CreateTap(ctx, spec.VMID, ip, spec.Network)
}
// ownedPID is the pid of the cloud-hypervisor 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 cloud-hypervisor process for vmID is still alive,
// by pidfile and signal 0.
//
// PID-liveness within this host boot. A pidfile written before the last boot
// reports not-running outright (see ownedPID), so the recycled-pid
// false-positive this used to concede is gone; within one boot, a pid this
// agent wrote cannot have been recycled while its process is alive. reconcile's
// own boot-ID check still decides what a lost VM means — this only decides
// which process, if any, is ours to ask about.
// logPath is where cloud-hypervisor's own diagnostic output is kept (startup
// errors, API logs) — distinct from serial.log, which carries the GUEST's
// console.
func (p *Provisioner) logPath(vmID string) string {
return filepath.Join(p.st.VMDir(vmID), "ch.log")
}
// FailureReason quotes the last thing cloud-hypervisor said. See
// reconcile.Provisioner.
func (p *Provisioner) FailureReason(vmID string) string { return hyperlog.Reason(p.logPath(vmID)) }
func (p *Provisioner) Running(vmID string) bool {
pid := p.ownedPID(vmID)
if pid == 0 {
return false
}
return p.signal(pid, 0) == nil
}
// Address returns the address reserved for vmID on the bridge, or "" when the
// VM has none. This backend allocates before the guest boots, so the answer is
// available the instant Boot returns — reconcile polls it either way, because a
// backend whose host OS hands out addresses cannot answer that early.
func (p *Provisioner) Address(vmID string) string { return p.net.Address(vmID) }
// NetworkAddress returns what the site's DHCP server granted this VM's
// named-network NIC, or "" when it has none or has not been heard from yet.
// Unlike Address it is genuinely polled: the host discovers it by watching the
// guest's own exchange, which cannot have happened before the guest boots.
func (p *Provisioner) NetworkAddress(vmID string) string { return p.net.NetworkAddress(vmID) }
// socketClient returns an *http.Client whose transport dials over the VM's
// Unix socket.
func (p *Provisioner) socketClient(vmID string) *http.Client {
sockPath := p.st.SocketPath(vmID)
return &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", sockPath)
},
},
}
}
// Shutdown powers the guest off and takes its serial console with it: a
// powered-off guest has no console to replay. The pump's ring is 256 KiB of
// backlog replayed to every new viewer, so a pump left running across a stop
// would answer for a guest that is not there — the next viewer, or the next
// boot proof, reads the dead boot's login prompt as the live one's.
//
// A shutdown that FAILED leaves the pump alone: the guest may well still be
// running, and a running guest keeps its console. On the way back up Boot
// re-Ensures the pump, which opens the fresh socket with an empty ring.
//
// Console history is not lost with the ring. serial.log is opened O_APPEND at
// a path fixed per VM, so the next pump continues the same file (rotating once
// at 4 MiB) — what a stop costs is the tail of the guest's shutdown sequence,
// which is written after the power button is pressed and has no pump left to
// drain it.
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 requests a clean shutdown via the cloud-hypervisor power-button API.
// Falls back to SIGTERM via the PID file if the API call fails or returns a
// non-2xx status (e.g. 404/500 when CH is unhealthy or the VM is not running).
func (p *Provisioner) powerOff(ctx context.Context, vmID string) error {
client := p.socketClient(vmID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
"http://localhost/api/v1/vm.power-button", nil)
if err != nil {
return p.sigterm(vmID)
}
resp, err := client.Do(req)
if err != nil {
return p.sigterm(vmID)
}
resp.Body.Close()
// Treat any non-2xx response as a failure and fall through to SIGTERM.
// A 404 means the VM is not in a running state; a 500 means CH is unhealthy.
// Either way the power-button did not trigger a shutdown.
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 // already gone
}
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
// cloud-hypervisor process, its serial pump and socket, and its tap device with
// the DHCP reservation the tap carries.
//
// Both failures reach reconcile, because returning nil is a promise that
// nothing is left to reap and reconcile deletes the VM's record on the strength
// of it. Releasing the tap is the retryable one — `ip link del` under an expired
// pass context fails, and an orphaned eit-XXXXXXXX device has nothing left to
// reap it, since EnsureBridge does not sweep them. A kill the kernel refused is
// rarer and worse: the record and the VM directory would go while the guest
// kept running against unlinked files, leaving a hypervisor on the host that
// nothing on it or in the fleet can name.
func (p *Provisioner) Destroy(ctx context.Context, vmID string) error {
killErr := p.kill(vmID)
if err := p.net.DeleteTap(ctx, vmID); err != nil {
return err
}
return killErr
}
// kill SIGKILLs the cloud-hypervisor process for vmID, stops its serial pump,
// and removes the PID file and serial 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 + serial-socket removal come BEFORE the SIGKILL: a pump
// leaked past a failed kill would dial a deleted path forever. ch.sock is
// deliberately left in place — the next Boot clears a stale API socket
// itself, and reap's DeleteVM removes the whole VM dir.
if p.Pumps != nil {
p.Pumps.Stop(vmID)
}
_ = os.Remove(p.st.SerialSocketPath(vmID))
pid := p.ownedPID(vmID)
if pid != 0 {
if err := p.signal(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
// Keep the pidfile AND say so. A SIGKILL that failed with anything
// but ESRCH has not proved the process gone, so the record is the
// only handle left on it — and an error is what stops reconcile
// deleting the directory that record lives in.
return fmt.Errorf("SIGKILL %s (pid %d): %w", vmID, pid, err)
}
}
_ = os.Remove(p.pidPath(vmID))
return nil
}