45ac02ec
feat(agent): the host says whether it can run a guest, before create spends anything
a73x 2026-08-06 09:12
Commit message
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -124,6 +124,19 @@ that hosts stay on that LAN. | |||
| 124 | **Unverified**: one fleet host is a laptop that leaves the network, and a | 124 | **Unverified**: one fleet host is a laptop that leaves the network, and a |
| 125 | public hostname resolving to a public address would route the long way round. | 125 | public hostname resolving to a public address would route the long way round. |
| 126 | 126 | ||
| 127 | ### A backend that refuses preflight refuses everything after it | ||
| 128 | |||
| 129 | Reconcile asks the backend once, before the image fetch, whether this host can | ||
| 130 | run a guest at all, and treats a permanent refusal as the VM's verdict. | ||
| 131 | Underpins spending nothing—no download, no create slot—on a VM the host can | ||
| 132 | never boot, and reporting the host's own reason rather than the first expensive | ||
| 133 | step's symptom. | ||
| 134 | **Partly proven**: pinned in reconcile's tests, and `inert` is the only backend | ||
| 135 | that refuses today. It assumes refusal is a static property of the host; a | ||
| 136 | backend that could run guests only sometimes (a Mac whose VM entitlement comes | ||
| 137 | and goes) would need preflight consulted per attempt to stay honest, which is | ||
| 138 | why it is asked per create rather than once at start. | ||
| 139 | |||
| 127 | ### A gzipped image wraps a raw one | 140 | ### A gzipped image wraps a raw one |
| 128 | 141 | ||
| 129 | Gzip is transport, not a disk format, so a `.gz` download is decompressed and | 142 | Gzip is transport, not a disk format, so a `.gz` download is decompressed and |
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -154,6 +154,13 @@ func permanentf(format string, args ...any) error { | |||
| 154 | return permanentError{err: fmt.Errorf(format, args...)} | 154 | return permanentError{err: fmt.Errorf(format, args...)} |
| 155 | } | 155 | } |
| 156 | 156 | ||
| 157 | // Preflight passes unconditionally: this backend exists only on a host that | ||
| 158 | // runs guests, and the one thing it needs beyond itself — the | ||
| 159 | // cloud-hypervisor binary — is installed by the agent's bootstrap step at | ||
| 160 | // start, long before any create. Re-checking it per create would trade a | ||
| 161 | // clear startup failure for a per-VM one. | ||
| 162 | func (p *Provisioner) Preflight(_ context.Context) error { return nil } | ||
| 163 | |||
| 157 | // PrepareRootDisk creates the VM's root disk by making a reflink copy of | 164 | // PrepareRootDisk creates the VM's root disk by making a reflink copy of |
| 158 | // basePath (instant on XFS/btrfs; silent full-copy fallback on ext4) and then | 165 | // basePath (instant on XFS/btrfs; silent full-copy fallback on ext4) and then |
| 159 | // truncating it to spec.DiskGB gigabytes. | 166 | // truncating it to spec.DiskGB gigabytes. |
internal/agent/inert/inert.go
| Old | New | ||
|---|---|---|---|
| @@ -52,6 +52,12 @@ func refuse(cause error) error { | |||
| 52 | // refuses, and nothing is ever running. | 52 | // refuses, and nothing is ever running. |
| 53 | type Provisioner struct{} | 53 | type Provisioner struct{} |
| 54 | 54 | ||
| 55 | // Preflight is where a VM misplaced onto this host should now fail: reconcile | ||
| 56 | // asks it before the image fetch, so the answer an operator reads is this | ||
| 57 | // host's verdict rather than whatever the create's first expensive step | ||
| 58 | // happened to trip over. | ||
| 59 | func (Provisioner) Preflight(_ context.Context) error { return refuse(ErrNoRuntime) } | ||
| 60 | |||
| 55 | func (Provisioner) PrepareRootDisk(_ context.Context, _ state.VMSpec, _ string) error { | 61 | func (Provisioner) PrepareRootDisk(_ context.Context, _ state.VMSpec, _ string) error { |
| 56 | return refuse(ErrNoRuntime) | 62 | return refuse(ErrNoRuntime) |
| 57 | } | 63 | } |
internal/agent/inert/inert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -36,6 +36,7 @@ func TestProvisionerRefusesEveryLifecycleCallPermanently(t *testing.T) { | |||
| 36 | ctx := context.Background() | 36 | ctx := context.Background() |
| 37 | p := Provisioner{} | 37 | p := Provisioner{} |
| 38 | calls := map[string]error{ | 38 | calls := map[string]error{ |
| 39 | "Preflight": p.Preflight(ctx), | ||
| 39 | "PrepareRootDisk": p.PrepareRootDisk(ctx, state.VMSpec{}, "/base.img"), | 40 | "PrepareRootDisk": p.PrepareRootDisk(ctx, state.VMSpec{}, "/base.img"), |
| 40 | "Boot": p.Boot(ctx, "vm-1", state.VMSpec{}), | 41 | "Boot": p.Boot(ctx, "vm-1", state.VMSpec{}), |
| 41 | "Shutdown": p.Shutdown(ctx, "vm-1"), | 42 | "Shutdown": p.Shutdown(ctx, "vm-1"), |
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -56,6 +56,17 @@ import ( | |||
| 56 | // mechanism and do not generalize. What generalizes is the data: the | 56 | // mechanism and do not generalize. What generalizes is the data: the |
| 57 | // deterministic MAC, the host's subnet, and a VM's current address. | 57 | // deterministic MAC, the host's subnet, and a VM's current address. |
| 58 | type Provisioner interface { | 58 | type Provisioner interface { |
| 59 | // Preflight reports whether this backend can run a guest on this host at | ||
| 60 | // all. It is asked once per create attempt, before any expensive work, so | ||
| 61 | // that a host which can never boot a guest refuses in its own words instead | ||
| 62 | // of failing later at whatever step happens to notice first — on a host with | ||
| 63 | // no VM runtime, that was a multi-gigabyte image download followed by an | ||
| 64 | // error naming the image toolchain rather than the host. | ||
| 65 | // | ||
| 66 | // nil means the backend is willing to try; a Permanent error terminal-fails | ||
| 67 | // the VM in one attempt, exactly as a refusal from the lifecycle verbs does. | ||
| 68 | Preflight(ctx context.Context) error | ||
| 69 | |||
| 59 | // PrepareRootDisk materialises the VM's root disk from a base image | 70 | // PrepareRootDisk materialises the VM's root disk from a base image |
| 60 | // (clone + grow). It is root-disk-only by contract: reconcile's rebuild | 71 | // (clone + grow). It is root-disk-only by contract: reconcile's rebuild |
| 61 | // path calls create again, so routing a user volume through it would | 72 | // path calls create again, so routing a user volume through it would |
| @@ -648,6 +659,15 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 648 | return | 659 | return |
| 649 | } | 660 | } |
| 650 | 661 | ||
| 662 | // Ask the backend whether this host can run a guest before spending anything | ||
| 663 | // on finding out. Ahead of the throttle as well as the fetch: a create that | ||
| 664 | // can only be refused should not queue for a slot that a create which might | ||
| 665 | // succeed could be using. | ||
| 666 | if err := e.Prov.Preflight(ctx); err != nil { | ||
| 667 | e.failCreate(ctx, rec, err, res) | ||
| 668 | return | ||
| 669 | } | ||
| 670 | |||
| 651 | // Throttle: cap how many VMs on this host may be inside the I/O-heavy part | 671 | // Throttle: cap how many VMs on this host may be inside the I/O-heavy part |
| 652 | // of create at once. Per-VM workers made these concurrent — N simultaneous | 672 | // of create at once. Per-VM workers made these concurrent — N simultaneous |
| 653 | // creates mean N image downloads, N image decodes and N multi-GB disk | 673 | // creates mean N image downloads, N image decodes and N multi-GB disk |
internal/agent/reconcile/reconcile_test.go
| Old | New | ||
|---|---|---|---|
| @@ -36,9 +36,10 @@ type fakeProv struct { | |||
| 36 | booted []string | 36 | booted []string |
| 37 | shutdown []string | 37 | shutdown []string |
| 38 | destroyed []string | 38 | destroyed []string |
| 39 | prepErr error | 39 | prepErr error |
| 40 | bootErr error // one-shot: consumed and cleared on first Boot call | 40 | bootErr error // one-shot: consumed and cleared on first Boot call |
| 41 | destroyErr error // sticky: every Destroy fails until it is cleared | 41 | destroyErr error // sticky: every Destroy fails until it is cleared |
| 42 | preflightErr error // sticky: the backend refuses this host outright | ||
| 42 | 43 | ||
| 43 | cidr string | 44 | cidr string |
| 44 | addrs map[string]string // vmID -> ip (sticky, mirrors the DHCP table) | 45 | addrs map[string]string // vmID -> ip (sticky, mirrors the DHCP table) |
| @@ -57,6 +58,12 @@ func newFakeProv() *fakeProv { | |||
| 57 | } | 58 | } |
| 58 | } | 59 | } |
| 59 | 60 | ||
| 61 | func (f *fakeProv) Preflight(_ context.Context) error { | ||
| 62 | f.mu.Lock() | ||
| 63 | defer f.mu.Unlock() | ||
| 64 | return f.preflightErr | ||
| 65 | } | ||
| 66 | |||
| 60 | func (f *fakeProv) PrepareRootDisk(_ context.Context, s state.VMSpec, _ string) error { | 67 | func (f *fakeProv) PrepareRootDisk(_ context.Context, s state.VMSpec, _ string) error { |
| 61 | f.mu.Lock() | 68 | f.mu.Lock() |
| 62 | defer f.mu.Unlock() | 69 | defer f.mu.Unlock() |
| @@ -637,6 +644,41 @@ func TestPermanentCreateErrorFailsTerminallyInOneAttempt(t *testing.T) { | |||
| 637 | assert.Equal(t, 1, f.prov.prepCalls, "no further provisioner attempts after a permanent failure") | 644 | assert.Equal(t, 1, f.prov.prepCalls, "no further provisioner attempts after a permanent failure") |
| 638 | } | 645 | } |
| 639 | 646 | ||
| 647 | // TestPreflightRefusalFailsBeforeTheImageFetch is the ordering this seam exists | ||
| 648 | // for. A host that cannot run guests must say so before create spends anything: | ||
| 649 | // the image fetch is a multi-gigabyte download, and letting it run first means | ||
| 650 | // the operator reads whatever it tripped over — an image error, say — instead | ||
| 651 | // of the host's own verdict. | ||
| 652 | func TestPreflightRefusalFailsBeforeTheImageFetch(t *testing.T) { | ||
| 653 | f := setup(t) | ||
| 654 | var fetches int | ||
| 655 | f.eng.Images = func(context.Context, string, string) (string, error) { | ||
| 656 | fetches++ | ||
| 657 | return "/cache/x.raw", nil | ||
| 658 | } | ||
| 659 | f.prov.preflightErr = permErr{"no VM runtime on this host (darwin/arm64)"} | ||
| 660 | |||
| 661 | rep := f.step(snap(1, vm("vm1"))) | ||
| 662 | row := findVM(rep, "vm1") | ||
| 663 | require.NotNil(t, row) | ||
| 664 | assert.Equal(t, "failed", row.Phase, "a permanent refusal is terminal on attempt 1") | ||
| 665 | assert.Contains(t, row.LastError, "no VM runtime on this host", | ||
| 666 | "the reported error must be the host's verdict, not a later step's symptom") | ||
| 667 | assert.Zero(t, fetches, "must not download an image for a VM this host can never boot") | ||
| 668 | assert.Zero(t, f.prov.prepCalls, "must not reach the disk either") | ||
| 669 | } | ||
| 670 | |||
| 671 | // TestPreflightPassingLeavesCreateUnchanged guards the other direction: the new | ||
| 672 | // gate must be invisible on a host that can run guests. | ||
| 673 | func TestPreflightPassingLeavesCreateUnchanged(t *testing.T) { | ||
| 674 | f := setup(t) | ||
| 675 | rep := f.step(snap(1, vm("vm1"))) | ||
| 676 | row := findVM(rep, "vm1") | ||
| 677 | require.NotNil(t, row) | ||
| 678 | assert.Equal(t, "ready", row.Phase) | ||
| 679 | assert.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 680 | } | ||
| 681 | |||
| 640 | // TestTwoVMsCreatedInOneStepGetDistinctIPs pins that when a single Step creates | 682 | // TestTwoVMsCreatedInOneStepGetDistinctIPs pins that when a single Step creates |
| 641 | // two VMs, each gets a distinct address. The backend owns the used-address set | 683 | // two VMs, each gets a distinct address. The backend owns the used-address set |
| 642 | // under its own lock (netenv's DHCP table does; the fake mirrors it), so two | 684 | // under its own lock (netenv's DHCP table does; the fake mirrors it), so two |
internal/agent/syncclient/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -30,6 +30,7 @@ import ( | |||
| 30 | // can drive a real Client.Run against a real syncsvc server over QUIC loopback. | 30 | // can drive a real Client.Run against a real syncsvc server over QUIC loopback. |
| 31 | type noopProv struct{} | 31 | type noopProv struct{} |
| 32 | 32 | ||
| 33 | func (noopProv) Preflight(context.Context) error { return nil } | ||
| 33 | func (noopProv) PrepareRootDisk(context.Context, state.VMSpec, string) error { return nil } | 34 | func (noopProv) PrepareRootDisk(context.Context, state.VMSpec, string) error { return nil } |
| 34 | func (noopProv) Boot(context.Context, string, state.VMSpec) error { return nil } | 35 | func (noopProv) Boot(context.Context, string, state.VMSpec) error { return nil } |
| 35 | func (noopProv) Shutdown(context.Context, string) error { return nil } | 36 | func (noopProv) Shutdown(context.Context, string) error { return nil } |