a73x

fd31fa9e

feat(agent): reconcile each VM in its own worker

a73x   2026-07-26 08:24

Commit message
feat(agent): reconcile each VM in its own worker

The agent drove every VM on a host in one synchronous pass, so a slow
operation on one VM — an image fetch, a disk copy, a boot — delayed every
other VM and, critically, the host's ActualStateReport. That report is the
heartbeat: the control plane marks a host offline after 30s of silence, so
one slow VM could flap an entire healthy host offline.

Each VM now has its own long-lived worker goroutine. Step keeps its
signature and becomes a router:

  fence stale snapshots
    -> dispatch one assignment per VM to that VM's worker (spawn/reap)
    -> aggregate each worker's last-published result, never waiting

Serialization of a single VM's operations is structural — one goroutine per
VM, no per-VM lock. Everything shared between VMs is guarded where it lives:
the compute ledger under Engine.mu, the DHCP reservation table under the dhcp
server's own lock, and the state store by one file per VM written via rename.

Two deliberate consequences. The report is last-known rather than post-tick,
so a busy VM contributes its previous row — that is precisely what makes the
heartbeat unblockable. And compute freed by quarantining one VM reaches a
sibling on a later tick rather than the same one; a quota refusal is
non-terminal and the loop is level-triggered, so it self-heals.

The watchdog moves from bounding a whole host step to bounding one VM's pass
(StepTimeout -> VMTimeout, -step-timeout -> -vm-timeout), which also stops a
wedged VM from starving a sibling of its turn.

Concurrent creates are bounded so they cannot saturate the disk the heartbeat
path also reads: MaxConcurrentCreates (-max-concurrent-creates, default 4)
gates the I/O-heavy region of create, and imagecache collapses concurrent
fetches of the same image into a single download via singleflight. Abandoned
download and convert temporaries are swept at agent start.

Two properties the worker model leans on:

  - state.Get reports not-found only for IsNotExist: an unreadable record is
    not an absent one, so a pass that cannot observe its own record changes
    nothing and keeps its last-known row. Anything else would route a live VM
    back through create(), rebuilding its disk under a running guest and
    orphaning its hypervisor process.

  - A published vmResult is aliased by every report taken between two
    publishes, so aggregate clones each proto row into the report rather than
    depending on how many goroutines might marshal it.

syncclient is unchanged.

.golangci.yml
Old New
@@ -48,7 +48,7 @@ linters:
48 - -QF1008 48 - -QF1008
49 - -QF1011 49 - -QF1011
50 gocyclo: 50 gocyclo:
51 min-complexity: 20 # reconcile.Step is the one legit outlier; excluded below 51 min-complexity: 20 # reconcile.create is the one legit outlier; excluded below
52 funlen: 52 funlen:
53 lines: 90 53 lines: 90
54 statements: 60 54 statements: 60
@@ -107,12 +107,13 @@ linters:
107 # Auto-exclude files carrying a "DO NOT EDIT" generated header (the .pb.go). 107 # Auto-exclude files carrying a "DO NOT EDIT" generated header (the .pb.go).
108 generated: lax 108 generated: lax
109 rules: 109 rules:
110 # reconcile.Step is an intentionally linear, well-commented state machine. 110 # reconcile.create is an intentionally linear, well-commented state
111 # machine: every step is a create side effect with its own failure path.
111 - path: internal/agent/reconcile/reconcile.go 112 - path: internal/agent/reconcile/reconcile.go
112 linters: 113 linters:
113 - funlen 114 - funlen
114 - gocyclo 115 - gocyclo
115 source: "func \\(e \\*Engine\\) Step" 116 source: "func \\(e \\*Engine\\) create"
116 # Generated protobuf — belt-and-suspenders alongside `generated: lax`. 117 # Generated protobuf — belt-and-suspenders alongside `generated: lax`.
117 - path: 'internal/pb/.*\.pb\.go' 118 - path: 'internal/pb/.*\.pb\.go'
118 linters: 119 linters:
cmd/eitri-agent/main.go
Old New
@@ -31,8 +31,11 @@ import (
31 type agentConfig struct { 31 type agentConfig struct {
32 StateDir, CHBin, Firmware string 32 StateDir, CHBin, Firmware string
33 TombstoneGrace, VanishGrace time.Duration 33 TombstoneGrace, VanishGrace time.Duration
34 StepTimeout time.Duration 34 VMTimeout time.Duration
35 ImageCacheMaxGB int64 35 ImageCacheMaxGB int64
36 // MaxConcurrentCreates bounds how much of the per-VM workers' create
37 // concurrency reaches the disk at once (0 = unlimited).
38 MaxConcurrentCreates int
36 // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent offers the 39 // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent offers the
37 // fleet (0 = unlimited): advertised to the server AND enforced at VM boot. 40 // fleet (0 = unlimited): advertised to the server AND enforced at VM boot.
38 MaxVCPUs, MaxMemMB, MaxDiskGB int64 41 MaxVCPUs, MaxMemMB, MaxDiskGB int64
@@ -44,8 +47,9 @@ func main() {
44 firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") 47 firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)")
45 tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") 48 tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy")
46 vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") 49 vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone")
47 stepTimeout := flag.Duration("step-timeout", 15*time.Minute, "watchdog bound on one WHOLE reconcile step — all VMs, summed (0 disables); keep above the 10m image-download timeout") 50 vmTimeout := flag.Duration("vm-timeout", 15*time.Minute, "watchdog bound on ONE VM's reconcile pass (0 disables); keep above the 10m image-download timeout")
48 imageCacheMaxGB := flag.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)") 51 imageCacheMaxGB := flag.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)")
52 maxConcurrentCreates := flag.Int("max-concurrent-creates", 4, "cap how many VMs may be inside the I/O-heavy part of create at once (image fetch + disk copy); 0 = unlimited")
49 maxVCPUs := flag.Int64("max-vcpus", 0, "cap the total vCPUs this host offers the fleet (0 = unlimited; reserves headroom, advertised + enforced at boot)") 53 maxVCPUs := flag.Int64("max-vcpus", 0, "cap the total vCPUs this host offers the fleet (0 = unlimited; reserves headroom, advertised + enforced at boot)")
50 maxMemMB := flag.Int64("max-mem-mb", 0, "cap the total memory (MB) this host offers the fleet (0 = unlimited)") 54 maxMemMB := flag.Int64("max-mem-mb", 0, "cap the total memory (MB) this host offers the fleet (0 = unlimited)")
51 maxDiskGB := flag.Int64("max-disk-gb", 0, "cap the total disk (GB) this host offers the fleet (0 = unlimited)") 55 maxDiskGB := flag.Int64("max-disk-gb", 0, "cap the total disk (GB) this host offers the fleet (0 = unlimited)")
@@ -70,16 +74,17 @@ func main() {
70 } 74 }
71 75
72 runAgent(st, agentConfig{ 76 runAgent(st, agentConfig{
73 StateDir: *stateDir, 77 StateDir: *stateDir,
74 CHBin: *chBin, 78 CHBin: *chBin,
75 Firmware: *firmware, 79 Firmware: *firmware,
76 TombstoneGrace: *tombstoneGrace, 80 TombstoneGrace: *tombstoneGrace,
77 VanishGrace: *vanishGrace, 81 VanishGrace: *vanishGrace,
78 StepTimeout: *stepTimeout, 82 VMTimeout: *vmTimeout,
79 ImageCacheMaxGB: *imageCacheMaxGB, 83 ImageCacheMaxGB: *imageCacheMaxGB,
80 MaxVCPUs: *maxVCPUs, 84 MaxConcurrentCreates: *maxConcurrentCreates,
81 MaxMemMB: *maxMemMB, 85 MaxVCPUs: *maxVCPUs,
82 MaxDiskGB: *maxDiskGB, 86 MaxMemMB: *maxMemMB,
87 MaxDiskGB: *maxDiskGB,
83 }) 88 })
84 } 89 }
85 90
@@ -201,22 +206,27 @@ func runAgent(st *state.Store, cfg agentConfig) {
201 imageCacheMaxGB = 0 206 imageCacheMaxGB = 0
202 } 207 }
203 cache.MaxBytes = imageCacheMaxGB << 30 208 cache.MaxBytes = imageCacheMaxGB << 30
209 // Reclaim temps left by a previous agent killed mid-fetch or mid-convert.
210 // Must run here, before the reconcile loop starts fetching: a sweep cannot
211 // tell an abandoned temp from one an in-flight fetch is still writing.
212 cache.SweepTemps()
204 213
205 engine := &reconcile.Engine{ 214 engine := &reconcile.Engine{
206 St: st, 215 St: st,
207 Prov: prov, 216 Prov: prov,
208 Net: net, 217 Net: net,
209 Images: cache.Ensure, 218 Images: cache.Ensure,
210 Seed: seed.Build, 219 Seed: seed.Build,
211 BootID: syncclient.HostBootID, 220 BootID: syncclient.HostBootID,
212 Now: time.Now, 221 Now: time.Now,
213 TombstoneGrace: cfg.TombstoneGrace, 222 TombstoneGrace: cfg.TombstoneGrace,
214 VanishGrace: cfg.VanishGrace, 223 VanishGrace: cfg.VanishGrace,
215 MaxCreateAttempts: 3, 224 MaxCreateAttempts: 3,
216 StepTimeout: cfg.StepTimeout, 225 MaxConcurrentCreates: cfg.MaxConcurrentCreates,
217 MaxVCPUs: cfg.MaxVCPUs, 226 VMTimeout: cfg.VMTimeout,
218 MaxMemMB: cfg.MaxMemMB, 227 MaxVCPUs: cfg.MaxVCPUs,
219 MaxDiskGB: cfg.MaxDiskGB, 228 MaxMemMB: cfg.MaxMemMB,
229 MaxDiskGB: cfg.MaxDiskGB,
220 } 230 }
221 231
222 // Seed the admission ledger from persisted records so the first reconcile 232 // Seed the admission ledger from persisted records so the first reconcile
@@ -226,6 +236,13 @@ func runAgent(st *state.Store, cfg agentConfig) {
226 engine.SeedLedger(recs) 236 engine.SeedLedger(recs)
227 } 237 }
228 238
239 // The per-VM reconcile workers are deliberately NOT torn down here. Stopping
240 // the engine is terminal — it would make the next report an empty actual state,
241 // which the control plane reads as every VM on this host having vanished — and
242 // it would race an unjoined session worker that can still be mid-Step when ctx
243 // is cancelled (see the note below where pumps are torn down). The process is
244 // exiting; the OS reclaims the goroutines.
245
229 client := &syncclient.Client{ 246 client := &syncclient.Client{
230 Engine: engine, 247 Engine: engine,
231 St: st, 248 St: st,
docs/architecture.md
Old New
@@ -37,7 +37,7 @@ bridge IP (`assigned_ip`) via the agent.
37 | **R2** | No `internal/server` package shells out — the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). | 37 | **R2** | No `internal/server` package shells out — the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). |
38 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | 38 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. |
39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | 39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. |
40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | 40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. |
41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | 41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). |
42 | **R7** | `internal/integration/*` is test infrastructure only — no package under `internal/server/*`, `internal/agent/*`, or `cmd/*` may import it, even transitively. The sanctioned exceptions are the test-tooling launcher binaries that are the infrastructure's entry points: `cmd/eitri-smoketest`, `cmd/eitri-devstack`, `cmd/eitri-sandbox`. | `internal/arch` `TestProductionPlanesDoNotImportIntegrationTestInfra` (transitive). | 42 | **R7** | `internal/integration/*` is test infrastructure only — no package under `internal/server/*`, `internal/agent/*`, or `cmd/*` may import it, even transitively. The sanctioned exceptions are the test-tooling launcher binaries that are the infrastructure's entry points: `cmd/eitri-smoketest`, `cmd/eitri-devstack`, `cmd/eitri-sandbox`. | `internal/arch` `TestProductionPlanesDoNotImportIntegrationTestInfra` (transitive). |
43 43
go.mod
Old New
@@ -11,6 +11,7 @@ require (
11 github.com/quic-go/quic-go v0.48.2 11 github.com/quic-go/quic-go v0.48.2
12 github.com/stretchr/testify v1.11.1 12 github.com/stretchr/testify v1.11.1
13 golang.org/x/crypto v0.54.0 13 golang.org/x/crypto v0.54.0
14 golang.org/x/sync v0.20.0
14 google.golang.org/protobuf v1.36.11 15 google.golang.org/protobuf v1.36.11
15 gopkg.in/yaml.v3 v3.0.1 16 gopkg.in/yaml.v3 v3.0.1
16 modernc.org/sqlite v1.52.0 17 modernc.org/sqlite v1.52.0
@@ -49,7 +50,6 @@ require (
49 golang.org/x/mod v0.33.0 // indirect 50 golang.org/x/mod v0.33.0 // indirect
50 golang.org/x/net v0.56.0 // indirect 51 golang.org/x/net v0.56.0 // indirect
51 golang.org/x/oauth2 v0.35.0 // indirect 52 golang.org/x/oauth2 v0.35.0 // indirect
52 golang.org/x/sync v0.20.0 // indirect
53 golang.org/x/sys v0.47.0 // indirect 53 golang.org/x/sys v0.47.0 // indirect
54 golang.org/x/tools v0.42.0 // indirect 54 golang.org/x/tools v0.42.0 // indirect
55 modernc.org/libc v1.72.3 // indirect 55 modernc.org/libc v1.72.3 // indirect
internal/agent/imagecache/imagecache.go
Old New
@@ -2,9 +2,16 @@
2 // (raw-converted via qemu-img, LRU-evicted beyond MaxBytes). Layout: 2 // (raw-converted via qemu-img, LRU-evicted beyond MaxBytes). Layout:
3 // <dir>/<sha256>.raw — keyed by checksum (spec). LRU: a hit refreshes the 3 // <dir>/<sha256>.raw — keyed by checksum (spec). LRU: a hit refreshes the
4 // file's mtime, and after every successful Ensure the oldest .raw images 4 // file's mtime, and after every successful Ensure the oldest .raw images
5 // beyond MaxBytes are evicted (never the one just ensured). Eviction is safe 5 // beyond MaxBytes are evicted (never the one just ensured).
6 // for running VMs: PrepareDisk copies (reflink) the base, so nothing 6 //
7 // references it after create. 7 // Eviction is safe for running VMs: PrepareDisk copies (reflink) the base, so
8 // nothing references it after create. With concurrent per-VM creates there is a
9 // narrow window where one VM's evict can remove a base another VM just resolved
10 // but has not yet copied — it requires the two in-flight images to exceed
11 // MaxBytes between them, since evict removes least-recently-used first and a
12 // just-ensured image carries the newest mtime. The cost is one failed create
13 // attempt, retried on the next tick. Close it with a recency floor in evict if
14 // it ever bites in practice.
8 package imagecache 15 package imagecache
9 16
10 import ( 17 import (
@@ -21,6 +28,8 @@ import (
21 "sort" 28 "sort"
22 "time" 29 "time"
23 30
31 "golang.org/x/sync/singleflight"
32
24 "github.com/a73x/eitri/internal/agent/exec" 33 "github.com/a73x/eitri/internal/agent/exec"
25 ) 34 )
26 35
@@ -48,6 +57,13 @@ type Cache struct {
48 // eagerly, never too late), but a huge-virtual-size image can pin the 57 // eagerly, never too late), but a huge-virtual-size image can pin the
49 // cache over cap; use block-based accounting if that ever bites. 58 // cache over cap; use block-based accounting if that ever bites.
50 MaxBytes int64 59 MaxBytes int64
60
61 // fetching collapses concurrent Ensure calls for the same image into one
62 // download+convert. Per-VM reconcile workers made creates concurrent, so a
63 // fleet rolling out one image now fetches it from every VM's worker at once;
64 // without this that is N identical multi-GB downloads and N qemu-img
65 // converts, all racing to rename onto the same final path.
66 fetching singleflight.Group
51 } 67 }
52 68
53 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout 69 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout
@@ -93,12 +109,40 @@ func (c *Cache) fetch(ctx context.Context, url, sha string) (string, error) {
93 return tmp.Name(), nil 109 return tmp.Name(), nil
94 } 110 }
95 111
112 // Ensure returns the local path to the raw base image for sha, fetching and
113 // converting it if the cache does not already hold it.
114 //
115 // Concurrent calls for the SAME sha are collapsed into one fetch: the first
116 // caller does the work and the rest wait for its result. Images are content-
117 // addressed, so a shared result is by definition the right one.
118 //
119 // Two consequences of sharing, both tolerable because the reconcile loop is
120 // level-triggered and simply retries on the next tick: the shared fetch is
121 // bounded by the FIRST caller's context, so if that caller's pass times out the
122 // waiters inherit its error; and a waiter whose own context expires first stops
123 // waiting without cancelling the fetch, which continues for the others.
96 func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) { 124 func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) {
97 // Guard path traversal: sha becomes part of the cache file path. 125 // Guard path traversal: sha becomes part of the cache file path. Checked
126 // before the singleflight so a bad digest can never key an entry.
98 if !sha256Re.MatchString(sha) { 127 if !sha256Re.MatchString(sha) {
99 return "", fmt.Errorf("invalid sha256: %q", sha) 128 return "", fmt.Errorf("invalid sha256: %q", sha)
100 } 129 }
101 130
131 ch := c.fetching.DoChan(sha, func() (any, error) { return c.ensureOnce(ctx, url, sha) })
132 select {
133 case r := <-ch:
134 if r.Err != nil {
135 return "", r.Err
136 }
137 return r.Val.(string), nil
138 case <-ctx.Done():
139 return "", ctx.Err()
140 }
141 }
142
143 // ensureOnce is the un-deduplicated body of Ensure: at most one runs per sha at
144 // a time.
145 func (c *Cache) ensureOnce(ctx context.Context, url, sha string) (string, error) {
102 final := filepath.Join(c.dir, sha+".raw") 146 final := filepath.Join(c.dir, sha+".raw")
103 if _, err := os.Stat(final); err == nil { 147 if _, err := os.Stat(final); err == nil {
104 // Hit: refresh recency so frequently-used images sort as recent. 148 // Hit: refresh recency so frequently-used images sort as recent.
@@ -136,6 +180,30 @@ func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) {
136 return final, nil 180 return final, nil
137 } 181 }
138 182
183 // SweepTemps removes abandoned download and convert temporaries — the two temp
184 // forms Ensure writes before its atomic rename. Neither matches evict's "*.raw"
185 // glob, so nothing else ever reclaims them, and an agent killed mid-fetch (the
186 // deploy path SIGTERMs it) leaves one behind per in-flight image.
187 //
188 // MUST be called at agent start, before any Ensure: it cannot distinguish an
189 // abandoned temp from one a concurrent fetch is still writing. At process start
190 // this agent has no fetch in flight, and the deploy script confirms the previous
191 // agent is gone before starting the new one, so every temp present is abandoned.
192 // Best-effort: a file that cannot be removed is left for the next start.
193 func (c *Cache) SweepTemps() {
194 for _, pat := range []string{"download-*", "*.raw.converting-*"} {
195 matches, err := filepath.Glob(filepath.Join(c.dir, pat))
196 if err != nil {
197 continue
198 }
199 for _, p := range matches {
200 if err := os.Remove(p); err == nil {
201 slog.Info("imagecache: swept abandoned temp", "path", p)
202 }
203 }
204 }
205 }
206
139 // evict removes least-recently-used .raw images until the cache fits 207 // evict removes least-recently-used .raw images until the cache fits
140 // MaxBytes, never touching keep (the image the current create is about to 208 // MaxBytes, never touching keep (the image the current create is about to
141 // use). Failures are logged-by-omission best-effort: eviction must never 209 // use). Failures are logged-by-omission best-effort: eviction must never
internal/agent/imagecache/imagecache_test.go
Old New
@@ -10,6 +10,7 @@ import (
10 "net/http/httptest" 10 "net/http/httptest"
11 "os" 11 "os"
12 "path/filepath" 12 "path/filepath"
13 "sync/atomic"
13 "testing" 14 "testing"
14 "time" 15 "time"
15 16
@@ -193,6 +194,30 @@ func TestCacheHitRefreshesRecency(t *testing.T) {
193 assert.True(t, after.ModTime().After(before.ModTime()), "hit must refresh mtime") 194 assert.True(t, after.ModTime().After(before.ModTime()), "hit must refresh mtime")
194 } 195 }
195 196
197 // TestSweepTempsRemovesAbandonedTempsOnly pins the start-up sweep: both temp
198 // forms Ensure writes before its atomic rename are reclaimed, and a real cached
199 // image — which evict alone accounts for — is left untouched.
200 func TestSweepTempsRemovesAbandonedTempsOnly(t *testing.T) {
201 dir := t.TempDir()
202 sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
203
204 download := filepath.Join(dir, "download-1234")
205 converting := filepath.Join(dir, sha+".raw.converting-5678")
206 image := filepath.Join(dir, sha+".raw")
207 for _, p := range []string{download, converting, image} {
208 require.NoError(t, os.WriteFile(p, []byte("bytes"), 0o644))
209 }
210
211 New(dir, fakeRunner(t)).SweepTemps()
212
213 _, err := os.Stat(download)
214 assert.True(t, os.IsNotExist(err), "abandoned download temp must be swept")
215 _, err = os.Stat(converting)
216 assert.True(t, os.IsNotExist(err), "abandoned convert temp must be swept")
217 _, err = os.Stat(image)
218 assert.NoError(t, err, "a real cached image must survive the sweep")
219 }
220
196 // TestEvictionNeverRemovesEnsuredImageEvenOverCap pins the best-effort cap: 221 // TestEvictionNeverRemovesEnsuredImageEvenOverCap pins the best-effort cap:
197 // the just-ensured image survives even when it alone exceeds MaxBytes. 222 // the just-ensured image survives even when it alone exceeds MaxBytes.
198 func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) { 223 func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) {
@@ -204,3 +229,62 @@ func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) {
204 _, err := os.Stat(a) 229 _, err := os.Stat(a)
205 assert.NoError(t, err, "cap is best-effort; the image in use survives") 230 assert.NoError(t, err, "cap is best-effort; the image in use survives")
206 } 231 }
232
233 // TestConcurrentEnsureFetchesOnce pins the singleflight: per-VM reconcile
234 // workers create VMs concurrently, so a fleet rolling out one image asks for it
235 // from several workers at once. Those must collapse into ONE download+convert
236 // rather than N identical multi-GB fetches racing to rename onto one path.
237 func TestConcurrentEnsureFetchesOnce(t *testing.T) {
238 body := []byte("base image bytes")
239 sum := sha256.Sum256(body)
240 sha := hex.EncodeToString(sum[:])
241
242 var downloads int32
243 release := make(chan struct{})
244 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245 atomic.AddInt32(&downloads, 1)
246 <-release // hold every request open so the callers genuinely overlap
247 w.Write(body)
248 }))
249 defer srv.Close()
250
251 c := New(t.TempDir(), fakeRunner(t))
252
253 const callers = 4
254 paths := make(chan string, callers)
255 errs := make(chan error, callers)
256 for i := 0; i < callers; i++ {
257 go func() {
258 p, err := c.Ensure(context.Background(), srv.URL, sha)
259 if err != nil {
260 errs <- err
261 return
262 }
263 paths <- p
264 }()
265 }
266
267 // Let them all arrive at the singleflight, then let the one download finish.
268 require.Eventually(t, func() bool { return atomic.LoadInt32(&downloads) >= 1 },
269 2*time.Second, 10*time.Millisecond, "no caller reached the server")
270 time.Sleep(100 * time.Millisecond) // any un-deduplicated caller would arrive by now
271 close(release)
272
273 want := filepath.Join(c.dir, sha+".raw")
274 for i := 0; i < callers; i++ {
275 select {
276 case err := <-errs:
277 t.Fatalf("Ensure failed: %v", err)
278 case p := <-paths:
279 assert.Equal(t, want, p)
280 case <-time.After(5 * time.Second):
281 t.Fatal("Ensure never returned")
282 }
283 }
284 assert.Equal(t, int32(1), atomic.LoadInt32(&downloads),
285 "concurrent Ensure calls for one image must share a single download")
286
287 data, err := os.ReadFile(want)
288 require.NoError(t, err)
289 assert.Equal(t, body, data, "the shared result must be the real image")
290 }
internal/agent/reconcile/managerhelpers_test.go
Old New
@@ -0,0 +1,29 @@
1 package reconcile
2
3 // Test-only introspection for the per-VM worker manager. Neither runs in
4 // production — the agent never counts its workers, and it never waits on them
5 // (not blocking on workers is the whole point of this layer). They live in the
6 // test build so the production package has no unreachable methods, while tests
7 // can observe a tick's work before asserting on it.
8
9 // count reports the number of live workers (one per VM this host is tracking).
10 func (m *manager) count() int {
11 m.mu.Lock()
12 defer m.mu.Unlock()
13 return len(m.workers)
14 }
15
16 // waitIdle blocks until every worker has consumed its pending assignment and
17 // finished the resulting pass, so a test can assert on a tick's work.
18 func (m *manager) waitIdle() {
19 for _, w := range m.snapshot() {
20 w.mu.Lock()
21 // !w.stopped is an escape, not a nicety: a worker stopped while an
22 // assignment was still pending returns from run without ever clearing
23 // pending, and this would otherwise wait on it forever.
24 for (w.pending != nil || w.busy) && !w.stopped {
25 w.cond.Wait()
26 }
27 w.mu.Unlock()
28 }
29 }
internal/agent/reconcile/quota_test.go
Old New
@@ -1,7 +1,6 @@
1 package reconcile 1 package reconcile
2 2
3 import ( 3 import (
4 "context"
5 "testing" 4 "testing"
6 5
7 "github.com/a73x/eitri/internal/pb" 6 "github.com/a73x/eitri/internal/pb"
@@ -17,7 +16,7 @@ func withRes(vcpus, memMB, diskGB int64) func(*pb.VMDesired) {
17 func TestQuotaUnderCapBoots(t *testing.T) { 16 func TestQuotaUnderCapBoots(t *testing.T) {
18 f := setup(t) 17 f := setup(t)
19 f.eng.MaxVCPUs = 4 18 f.eng.MaxVCPUs = 4
20 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(2, 512, 5)))) 19 rep := f.step(snap(1, vm("vm1", withRes(2, 512, 5))))
21 assert.Equal(t, []string{"vm1"}, f.prov.booted) 20 assert.Equal(t, []string{"vm1"}, f.prov.booted)
22 assert.Equal(t, "ready", findVM(rep, "vm1").Phase) 21 assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
23 } 22 }
@@ -25,7 +24,7 @@ func TestQuotaUnderCapBoots(t *testing.T) {
25 func TestQuotaOverCapRefusedNamesDimension(t *testing.T) { 24 func TestQuotaOverCapRefusedNamesDimension(t *testing.T) {
26 f := setup(t) 25 f := setup(t)
27 f.eng.MaxVCPUs = 2 26 f.eng.MaxVCPUs = 2
28 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(4, 512, 5)))) 27 rep := f.step(snap(1, vm("vm1", withRes(4, 512, 5))))
29 assert.Empty(t, f.prov.booted, "an over-cap VM must not boot") 28 assert.Empty(t, f.prov.booted, "an over-cap VM must not boot")
30 assert.Empty(t, f.prov.prepared, "an over-cap VM must not even prepare a disk") 29 assert.Empty(t, f.prov.prepared, "an over-cap VM must not even prepare a disk")
31 av := findVM(rep, "vm1") 30 av := findVM(rep, "vm1")
@@ -38,20 +37,20 @@ func TestQuotaOverCapRefusedNamesDimension(t *testing.T) {
38 func TestQuotaEnforcesMemAndDiskIndependently(t *testing.T) { 37 func TestQuotaEnforcesMemAndDiskIndependently(t *testing.T) {
39 f := setup(t) 38 f := setup(t)
40 f.eng.MaxMemMB = 1024 39 f.eng.MaxMemMB = 1024
41 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(1, 4096, 5)))) 40 rep := f.step(snap(1, vm("vm1", withRes(1, 4096, 5))))
42 assert.Empty(t, f.prov.booted) 41 assert.Empty(t, f.prov.booted)
43 assert.Contains(t, findVM(rep, "vm1").GetLastError(), "memory") 42 assert.Contains(t, findVM(rep, "vm1").GetLastError(), "memory")
44 43
45 g := setup(t) 44 g := setup(t)
46 g.eng.MaxDiskGB = 10 45 g.eng.MaxDiskGB = 10
47 rep = g.eng.Step(context.Background(), snap(1, vm("vm1", withRes(1, 512, 50)))) 46 rep = g.step(snap(1, vm("vm1", withRes(1, 512, 50))))
48 assert.Empty(t, g.prov.booted) 47 assert.Empty(t, g.prov.booted)
49 assert.Contains(t, findVM(rep, "vm1").GetLastError(), "disk") 48 assert.Contains(t, findVM(rep, "vm1").GetLastError(), "disk")
50 } 49 }
51 50
52 func TestQuotaZeroMeansUnlimited(t *testing.T) { 51 func TestQuotaZeroMeansUnlimited(t *testing.T) {
53 f := setup(t) // caps default to 0 52 f := setup(t) // caps default to 0
54 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(999, 999999, 99999)))) 53 rep := f.step(snap(1, vm("vm1", withRes(999, 999999, 99999))))
55 assert.Equal(t, []string{"vm1"}, f.prov.booted, "no caps configured = unlimited") 54 assert.Equal(t, []string{"vm1"}, f.prov.booted, "no caps configured = unlimited")
56 assert.Equal(t, "ready", findVM(rep, "vm1").Phase) 55 assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
57 } 56 }
@@ -60,36 +59,49 @@ func TestQuotaRefusalIsNonTerminal(t *testing.T) {
60 f := setup(t) 59 f := setup(t)
61 f.eng.MaxVCPUs = 2 60 f.eng.MaxVCPUs = 2
62 // vm1 (2 vcpus) fills the cap exactly and boots. 61 // vm1 (2 vcpus) fills the cap exactly and boots.
63 f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(2, 512, 5)))) 62 f.step(snap(1, vm("vm1", withRes(2, 512, 5))))
64 require.Equal(t, []string{"vm1"}, f.prov.booted) 63 require.Equal(t, []string{"vm1"}, f.prov.booted)
65 64
66 // vm2 (2 vcpus) would push the host to 4 > 2 — blocked. Repeat well past 65 // vm2 (2 vcpus) would push the host to 4 > 2 — blocked. Repeat well past
67 // MaxCreateAttempts (3): quota refusal must NOT consume the retry budget. 66 // MaxCreateAttempts (3): quota refusal must NOT consume the retry budget.
68 for i := 0; i < 5; i++ { 67 for i := 0; i < 5; i++ {
69 f.eng.Step(context.Background(), snap(2, vm("vm1", withRes(2, 512, 5)), vm("vm2", withRes(2, 512, 5)))) 68 f.step(snap(2, vm("vm1", withRes(2, 512, 5)), vm("vm2", withRes(2, 512, 5))))
70 } 69 }
71 assert.Equal(t, []string{"vm1"}, f.prov.booted, "vm2 still blocked, vm1 untouched") 70 assert.Equal(t, []string{"vm1"}, f.prov.booted, "vm2 still blocked, vm1 untouched")
72 71
73 // Room frees (operator raises the cap). A blocked-forever VM whose budget 72 // Room frees (operator raises the cap). A blocked-forever VM whose budget
74 // had been burned would be terminal-failed; a non-terminal one boots now. 73 // had been burned would be terminal-failed; a non-terminal one boots now.
75 f.eng.MaxVCPUs = 10 74 f.eng.MaxVCPUs = 10
76 f.eng.Step(context.Background(), snap(3, vm("vm1", withRes(2, 512, 5)), vm("vm2", withRes(2, 512, 5)))) 75 f.step(snap(3, vm("vm1", withRes(2, 512, 5)), vm("vm2", withRes(2, 512, 5))))
77 assert.Equal(t, []string{"vm1", "vm2"}, f.prov.booted, "vm2 boots once room frees") 76 assert.Equal(t, []string{"vm1", "vm2"}, f.prov.booted, "vm2 boots once room frees")
78 } 77 }
79 78
80 func TestQuotaExcludesQuarantinedFromLiveSum(t *testing.T) { 79 // TestQuotaFreedByQuarantineEventuallyBootsTheWaitingVM pins that quarantined
80 // VMs do not count against the host cap — and that the guarantee is EVENTUAL,
81 // not same-tick. Each VM reconciles independently, so vm2's admission may run
82 // before vm1's quarantine has released its compute; the refusal is
83 // non-terminal and the loop is level-triggered, so a later tick boots it.
84 func TestQuotaFreedByQuarantineEventuallyBootsTheWaitingVM(t *testing.T) {
81 f := setup(t) 85 f := setup(t)
82 f.eng.MaxVCPUs = 3 86 f.eng.MaxVCPUs = 3
83 // vm1 (2 vcpus) boots. 87 // vm1 (2 vcpus) boots and holds 2 of the 3-vcpu cap.
84 f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(2, 512, 5)))) 88 f.step(snap(1, vm("vm1", withRes(2, 512, 5))))
85 require.Equal(t, []string{"vm1"}, f.prov.booted) 89 require.Equal(t, []string{"vm1"}, f.prov.booted)
86 90
87 // Tombstone vm1 (→ quarantined, stopped, awaiting destroy) and desire vm2 91 // Tombstone vm1 (→ quarantined, compute released) and desire vm2 (2 vcpus).
88 // (2 vcpus). Counting vm1 would give 4 > 3 and block vm2; excluding the 92 // Counting the quarantined vm1 would give 4 > 3 and block vm2 forever.
89 // quarantined record leaves 2 <= 3, so vm2 boots. 93 // Two ticks suffice deterministically: whatever the map order, tick 1 reaps
90 rep := f.eng.Step(context.Background(), snap(2, 94 // vm1 and releases its compute, so tick 2 always admits vm2. The third is
91 tombstoned(vm("vm1", withRes(2, 512, 5))), 95 // slack, not a flake bound.
92 vm("vm2", withRes(2, 512, 5)))) 96 var rep *pb.ActualStateReport
97 for i := 0; i < 3; i++ {
98 rep = f.step(snap(2,
99 tombstoned(vm("vm1", withRes(2, 512, 5))),
100 vm("vm2", withRes(2, 512, 5))))
101 if findVM(rep, "vm2").GetPhase() == "ready" {
102 break
103 }
104 }
93 assert.Contains(t, f.prov.booted, "vm2", "quarantined vm1 must not count against the cap") 105 assert.Contains(t, f.prov.booted, "vm2", "quarantined vm1 must not count against the cap")
94 assert.Equal(t, "ready", findVM(rep, "vm2").Phase) 106 assert.Equal(t, "ready", findVM(rep, "vm2").GetPhase())
95 } 107 }
internal/agent/reconcile/reconcile.go
Old New
@@ -8,6 +8,13 @@
8 // destroyed[] ack = level-triggered: every tombstoned vm_id with no local 8 // destroyed[] ack = level-triggered: every tombstoned vm_id with no local
9 // record, repeated until the server hard-deletes it 9 // record, repeated until the server hard-deletes it
10 // 10 //
11 // Shape: Step is a router, not a worker. It fences stale snapshots, hands each
12 // VM its slice of desired state to that VM's own long-lived goroutine, and
13 // aggregates every worker's last-published result into the report — never
14 // waiting on a worker, so slow VM work cannot delay the host heartbeat. One
15 // goroutine per VM is also the serialization primitive: a single VM's
16 // operations are serial by construction. See worker.go.
17 //
11 // The "exists" definition deserves a comment: 18 // The "exists" definition deserves a comment:
12 // rec.BootID is the sole completion witness. create() sets it to the current 19 // rec.BootID is the sole completion witness. create() sets it to the current
13 // host boot ID only after every side effect (image, tap, disk, seed, boot) has 20 // host boot ID only after every side effect (image, tap, disk, seed, boot) has
@@ -25,10 +32,13 @@ import (
25 "encoding/json" 32 "encoding/json"
26 "errors" 33 "errors"
27 "fmt" 34 "fmt"
35 "log/slog"
28 "strings" 36 "strings"
29 "sync" 37 "sync"
30 "time" 38 "time"
31 39
40 "google.golang.org/protobuf/proto"
41
32 "github.com/a73x/eitri/internal/agent/seed" 42 "github.com/a73x/eitri/internal/agent/seed"
33 "github.com/a73x/eitri/internal/agent/state" 43 "github.com/a73x/eitri/internal/agent/state"
34 "github.com/a73x/eitri/internal/pb" 44 "github.com/a73x/eitri/internal/pb"
@@ -73,9 +83,9 @@ type Engine struct {
73 // Now returns the current time. Injectable for deterministic tests. 83 // Now returns the current time. Injectable for deterministic tests.
74 Now func() time.Time 84 Now func() time.Time
75 85
76 // mu guards committed. It serializes admission so concurrent per-VM callers 86 // mu guards committed. It serializes admission so the concurrent per-VM
77 // cannot oversubscribe a cap or double-count. Under the current 87 // workers cannot oversubscribe a cap or double-count. It is the one
78 // single-threaded step it is uncontended. 88 // cross-VM invariant the worker layer relies on (see admit).
79 mu sync.Mutex 89 mu sync.Mutex
80 // committed is the in-memory admission ledger: vm_id -> the spec whose 90 // committed is the in-memory admission ledger: vm_id -> the spec whose
81 // compute counts against the host caps. A VM is committed at create, 91 // compute counts against the host caps. A VM is committed at create,
@@ -83,6 +93,12 @@ type Engine struct {
83 // re-noted while it exists. Rebuilt from persisted records via SeedLedger. 93 // re-noted while it exists. Rebuilt from persisted records via SeedLedger.
84 committed map[string]state.VMSpec 94 committed map[string]state.VMSpec
85 95
96 // mgrOnce/mgr hold the per-VM worker manager, built on first use. It is not
97 // a constructor argument so that Step stays the single entry point and the
98 // agent needs no extra wiring beyond Stop at shutdown.
99 mgrOnce sync.Once
100 mgr *manager
101
86 // TombstoneGrace is the quarantine period for tombstoned VMs before destroy. 102 // TombstoneGrace is the quarantine period for tombstoned VMs before destroy.
87 TombstoneGrace time.Duration 103 TombstoneGrace time.Duration
88 104
@@ -92,6 +108,17 @@ type Engine struct {
92 // MaxCreateAttempts is the maximum number of create attempts before terminal failed. 108 // MaxCreateAttempts is the maximum number of create attempts before terminal failed.
93 MaxCreateAttempts int 109 MaxCreateAttempts int
94 110
111 // MaxConcurrentCreates caps how many VMs on this host may be inside the
112 // I/O-heavy part of create at once (image fetch, disk materialisation).
113 // Zero means unlimited. Per-VM workers made creates concurrent; this bounds
114 // how much of that concurrency reaches the disk. See acquireCreateSlot.
115 MaxConcurrentCreates int
116
117 // createSlotsOnce/createSlots hold the create throttle, built on first use
118 // so a zero-value Engine needs no constructor.
119 createSlotsOnce sync.Once
120 createSlots chan struct{}
121
95 // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent will 122 // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent will
96 // commit to live VMs (0 = unlimited). A create whose resources would push 123 // commit to live VMs (0 = unlimited). A create whose resources would push
97 // the running total past a cap is refused — see admit. This is the 124 // the running total past a cap is refused — see admit. This is the
@@ -100,194 +127,322 @@ type Engine struct {
100 MaxMemMB int64 127 MaxMemMB int64
101 MaxDiskGB int64 128 MaxDiskGB int64
102 129
103 // StepTimeout bounds one WHOLE Step call — the sum of all operations for 130 // VMTimeout bounds ONE VM's reconcile pass — every operation for that VM in
104 // all VMs in that step, not each operation. Zero disables the watchdog. 131 // that pass, not each operation and not the whole host tick. Zero disables
105 // A wedged operation (disk prep, seed build, image fetch) then fails with 132 // the watchdog. A wedged operation (disk prep, seed build, image fetch) then
106 // the ctx error instead of freezing the reconcile loop forever; the next 133 // fails with the ctx error instead of freezing that VM's reconcile forever;
107 // tick retries, and ctx-expiry failures are refunded from the create retry 134 // the next tick retries, and ctx-expiry failures are refunded from the create
108 // budget (see failCreate). Convergence across ticks is guaranteed because 135 // retry budget (see failCreate). Convergence across ticks is guaranteed
109 // completed image downloads are durably cached per-sha. Set comfortably 136 // because completed image downloads are durably cached per-sha. Set
110 // above the longest legitimate operation (imagecache's HTTP client allows 137 // comfortably above the longest legitimate operation (imagecache's HTTP
111 // 10m for a first-time image download). 138 // client allows 10m for a first-time image download).
112 StepTimeout time.Duration 139 VMTimeout time.Duration
113 } 140 }
114 141
115 // Step reconciles the desired snapshot against actual state and returns an 142 // assignment is one VM's slice of a desired-state snapshot. desired is nil when
116 // ActualStateReport for the server. 143 // the VM is absent from desired state entirely (vanished); tombstoned marks a
144 // desired entry flagged for deletion. Both mean "reap", and the pair stays
145 // explicit because the two carry different grace periods (VanishGrace vs the
146 // shorter TombstoneGrace).
117 // 147 //
118 // The algorithm is level-triggered: every call re-examines full state and 148 // LIFETIME: desired points into the caller's DesiredStateSnapshot, and a worker
119 // drives toward desired. Idempotent under repeated identical snapshots. 149 // holds that pointer well past the Step that delivered it — for as long as its
120 func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.ActualStateReport { 150 // pass runs, up to VMTimeout. A snapshot handed to Step must therefore be
121 // Watchdog: bound the whole step so one wedged operation cannot freeze the 151 // treated as immutable while any pass may still be running.
122 // reconcile loop (and with it all reports for this host) forever. Pinned by 152 type assignment struct {
123 // TestStepTimeoutBoundsSlowOperations. 153 desired *pb.VMDesired
124 if e.StepTimeout > 0 { 154 tombstoned bool
125 var cancel context.CancelFunc 155 }
126 ctx, cancel = context.WithTimeout(ctx, e.StepTimeout) 156
127 defer cancel() 157 // assignments slices a snapshot into one assignment per VM, over the UNION of
158 // desired state and local records: a desired-only id is a create, a record-only
159 // id is a vanished VM to reap, and an id in both converges. The union is also
160 // exactly the set of VMs that need a reconcile pass this tick.
161 func assignments(snap *pb.DesiredStateSnapshot, recs map[string]state.Record) map[string]assignment {
162 out := make(map[string]assignment, len(snap.Vms)+len(recs))
163 for _, d := range snap.Vms {
164 out[d.VmId] = assignment{desired: d, tombstoned: d.Tombstoned}
165 }
166 for id := range recs {
167 if _, ok := out[id]; !ok {
168 out[id] = assignment{} // absent from desired: vanished
169 }
128 } 170 }
171 return out
172 }
129 173
130 rep := &pb.ActualStateReport{} 174 // tombstonedSet returns the ids the control plane has flagged for deletion.
175 // The destroy ack is level-triggered from it (see ackDestroyed).
176 func tombstonedSet(snap *pb.DesiredStateSnapshot) map[string]bool {
177 out := make(map[string]bool, len(snap.Vms))
178 for _, d := range snap.Vms {
179 if d.Tombstoned {
180 out[d.VmId] = true
181 }
182 }
183 return out
184 }
131 185
186 // Step routes one desired-state snapshot to the per-VM workers and returns the
187 // host's ActualStateReport. It never waits for a worker: the report carries each
188 // VM's LAST-PUBLISHED state, so a VM busy in a multi-second operation cannot
189 // delay the heartbeat. A VM that has not published yet simply has no row.
190 //
191 // The algorithm is level-triggered: every call re-examines full state and drives
192 // toward desired. Idempotent under repeated identical snapshots.
193 //
194 // ctx is accepted for signature stability and bounds nothing here — the work
195 // happens in workers, where VMTimeout bounds each VM's pass.
196 func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.ActualStateReport {
132 // ── 1. Epoch fence ─────────────────────────────────────────────────────── 197 // ── 1. Epoch fence ───────────────────────────────────────────────────────
133 // CRITICAL: the fence path must touch NOTHING: no SaveEpoch, no provisioner 198 // CRITICAL: the fence path must touch NOTHING: no SaveEpoch, no provisioner
134 // calls, no state mutations. It returns the current actual state so the 199 // calls, no dispatch, no state mutations. It returns the current actual state
135 // server can observe what the agent actually has. 200 // so the control plane can observe what the agent actually has. A pass
201 // dispatched by an EARLIER, accepted snapshot may still be running — the
202 // fence refuses the stale snapshot, it does not freeze the host.
136 currentEpoch := e.St.Epoch() 203 currentEpoch := e.St.Epoch()
137 if snap.Epoch < currentEpoch { 204 if snap.Epoch < currentEpoch {
138 rep.FenceViolation = true 205 return e.fenceReport(currentEpoch)
139 rep.LastSeenEpoch = currentEpoch
140 // Fill Vms and Quarantined from current records so the server sees actual
141 // state. Quarantined VMs are reported in Quarantined[], not in Vms[].
142 // No mutations: fence path is strictly read-only.
143 if recs, err := e.St.LoadVMs(); err == nil {
144 for _, rec := range recs {
145 // Fix 4: quarantined VMs belong in Quarantined[], not Vms[].
146 if rec.QuarantinedAt != nil {
147 rep.Quarantined = append(rep.Quarantined, quarantinedEntry(rec, e.graceFor(rec)))
148 continue
149 }
150 power := "stopped"
151 if e.Prov.Running(rec.Spec.VMID) {
152 power = "running"
153 }
154 phase := "ready"
155 if rec.LastError != "" {
156 phase = "failed"
157 }
158 addReport(rep, rec.Spec.VMID, rec.IP, power, phase, rec.LastError)
159 }
160 }
161 return rep
162 } 206 }
163 207
164 // Advance epoch (equal is fine — same snapshot repeated). 208 // Advance epoch (equal is fine — same snapshot repeated).
165 _ = e.St.SaveEpoch(snap.Epoch) 209 _ = e.St.SaveEpoch(snap.Epoch)
166 rep.LastSeenEpoch = snap.Epoch
167
168 // ── 2. Build desired map + tombstoned set ─────────────────────────────────
169 desired := make(map[string]*pb.VMDesired, len(snap.Vms))
170 tombstoned := make(map[string]bool, len(snap.Vms))
171 for _, d := range snap.Vms {
172 desired[d.VmId] = d
173 if d.Tombstoned {
174 tombstoned[d.VmId] = true
175 }
176 }
177 210
178 // ── 3. Reap pass ────────────────────────────────────────────────────────── 211 // One record scan serves the whole tick: dispatch slices it into assignments
179 // For each local record: if it is absent from desired OR tombstoned, 212 // and aggregate acks destroys against it. Reading it twice cost a second
180 // quarantine it (shutting it down) or destroy it once grace expires. 213 // full state-dir scan on the one blocking path in Step, microseconds after
214 // the first, and bought no freshness worth having (see ackDestroyed).
181 recs, _ := e.St.LoadVMs() 215 recs, _ := e.St.LoadVMs()
182 for id, rec := range recs {
183 isTombstoned := tombstoned[id]
184 _, inDesired := desired[id]
185 if inDesired && !isTombstoned {
186 // Fix 1: un-delete path — VM re-appears in desired while still carrying
187 // a stale QuarantinedAt from a previous tombstone. Clear it so the NEXT
188 // delete starts a fresh grace window (not instant kill from stale timestamp).
189 if rec.QuarantinedAt != nil {
190 rec.QuarantinedAt = nil
191 rec.QuarantineTombstoned = false
192 _ = e.St.SaveVM(rec)
193 }
194 continue // active desired VM; handled in converge pass
195 }
196 216
197 // Being reaped (absent from desired or tombstoned): free its compute 217 // Deliberately NOT threaded with ctx: dispatch hands work to long-lived
198 // from the admission ledger so a new VM can use it. Idempotent; the IP 218 // per-VM workers whose passes outlive this Step by design, so inheriting
199 // reservation is released on destroy by DeleteTap. 219 // ctx would cancel them the instant the report went out. VMTimeout bounds
200 e.releaseCompute(id) 220 // each pass instead (see reconcileOne).
221 e.dispatch(snap, recs) //nolint:contextcheck // a pass deliberately outlives its Step
222 return e.aggregate(snap.Epoch, recs)
223 }
201 224
202 now := e.Now() 225 // dispatch hands every VM its slice of this snapshot and reaps the workers for
226 // VMs that are gone from both desired state and local records. recs is the
227 // caller's record view; the assignment set is the union of it and the snapshot.
228 //
229 // There is deliberately NO ordering between the resulting passes: a VM whose
230 // reap frees compute may run after a sibling's admission, so the sibling is
231 // quota-refused and boots on a later tick. The refusal is non-terminal and the
232 // loop is level-triggered, so the guarantee is eventual rather than same-tick.
233 func (e *Engine) dispatch(snap *pb.DesiredStateSnapshot, recs map[string]state.Record) {
234 live := assignments(snap, recs)
235
236 m := e.manager()
237 m.setTombstoned(tombstonedSet(snap))
238 for id, a := range live {
239 m.deliver(id, a)
240 }
241 m.reapAbsent(live)
242 }
203 243
204 if rec.QuarantinedAt == nil { 244 // aggregate builds the host report from each worker's last-published result
205 // First time we see this VM needs reaping: enter quarantine. 245 // plus the level-triggered destroy ack. It is a pure read of published state:
206 t := now 246 // safe to call at any time, and it blocks on nothing.
207 rec.QuarantinedAt = &t 247 //
208 rec.QuarantineTombstoned = isTombstoned 248 // epoch is the ACCEPTED snapshot's epoch, threaded in from the caller and
209 rec.StopRequested = true // record BEFORE side effects 249 // deliberately NOT re-read from disk: Store.Epoch fails open to 0, so one
210 // Fix 5: only Shutdown after the stop intent is durably persisted. 250 // transient read error would report LastSeenEpoch 0 on a snapshot this host has
211 // If SaveVM fails, skip Shutdown this cycle — the next reconcile 251 // just accepted and acted on — telling the control plane the agent is arbitrarily
212 // will retry. This upholds "record stop BEFORE stopping". 252 // far behind. The caller knows the epoch it accepted; that is the true answer.
213 if err := e.St.SaveVM(rec); err == nil { 253 //
214 _ = e.Prov.Shutdown(ctx, id) 254 // recs is the record view the destroy ack is computed against, with the caller
215 } 255 // choosing how fresh it is (see ackDestroyed).
216 } else if isTombstoned && !rec.QuarantineTombstoned { 256 func (e *Engine) aggregate(epoch uint64, recs map[string]state.Record) *pb.ActualStateReport {
217 // Upgrade: vanished quarantine → tombstoned quarantine (shorter grace). 257 rep := &pb.ActualStateReport{LastSeenEpoch: epoch}
218 rec.QuarantineTombstoned = true 258 m := e.manager()
219 _ = e.St.SaveVM(rec) 259 m.collect(rep)
260 e.ackDestroyed(rep, m.tombstones(), recs)
261 return rep
262 }
263
264 // fenceReport is the read-only report returned for a stale snapshot: current
265 // actual state, derived entirely from persisted records, with no mutation and
266 // no dispatch.
267 func (e *Engine) fenceReport(currentEpoch uint64) *pb.ActualStateReport {
268 rep := &pb.ActualStateReport{FenceViolation: true, LastSeenEpoch: currentEpoch}
269 recs, err := e.St.LoadVMs()
270 if err != nil {
271 return rep
272 }
273 for _, rec := range recs {
274 var res vmResult
275 // Quarantined VMs belong in Quarantined[], not Vms[].
276 if rec.QuarantinedAt != nil {
277 res.quarantined = quarantinedEntry(rec, e.graceFor(rec))
278 res.merge(rep)
279 continue
280 }
281 power := "stopped"
282 if e.Prov.Running(rec.Spec.VMID) {
283 power = "running"
284 }
285 phase := "ready"
286 if rec.LastError != "" {
287 phase = "failed"
220 } 288 }
289 res.report(rec.Spec.VMID, rec.IP, power, phase, rec.LastError)
290 res.merge(rep)
291 }
292 return rep
293 }
221 294
222 grace := e.graceFor(rec) 295 // reconcileOne is ONE VM's complete reconcile pass: bounded by VMTimeout, it
223 296 // loads that VM's own record and either drives it toward desired or reaps it.
224 if now.Sub(*rec.QuarantinedAt) >= grace { 297 // It is the entire unit of work a per-VM worker runs, and it touches no other
225 // Grace expired: destroy the VM. 298 // VM's record. The only shared state reconcile itself owns is the compute
226 // NOTE: this may run with an expired step ctx (watchdog). Safe today 299 // ledger, serialized under Engine.mu (see admit). The pass also reaches shared
227 // because cloudhv.Kill ignores ctx (SIGKILL via pidfile) and 300 // subsystems it does NOT own — the image cache (Images), the DHCP reservation
228 // Shutdown falls back to a ctx-free SIGTERM — a future Provisioner 301 // table (Net.CreateTap/DeleteTap), the serial-pump manager (Prov.Boot/Kill) —
229 // that honors ctx here would skip the destroy until a later tick, 302 // each of which carries its own locking.
230 // which the level-triggered loop tolerates but delays. 303 //
231 _ = e.Prov.Kill(ctx, id) 304 // The bool reports whether the pass RAN. A pass that cannot read this VM's own
232 if err := e.Net.DeleteTap(ctx, id); err != nil { 305 // record cannot tell an absent VM from a live one, so it changes nothing and
233 // TAP deletion failed — e.g. DeleteTap runs `ip link del` under 306 // publishes nothing: the loop is level-triggered, the next tick retries once the
234 // ctx and the ctx expired during a SIGTERM shutdown. KEEP the 307 // store recovers, and the VM keeps its last-known row in the report meanwhile
235 // record so a later tick retries; deleting it here would orphan 308 // (see worker.run). Acting on the unreadable record instead would route a
236 // the eit-XXXXXXXX interface with nothing left to reap it (agent 309 // running VM into create().
237 // restart's EnsureBridge does not sweep orphan taps). Kill already 310 func (e *Engine) reconcileOne(ctx context.Context, id string, a assignment) (vmResult, bool) {
238 // stopped the guest, so re-entering here next tick (grace still 311 if e.VMTimeout > 0 {
239 // expired) just retries DeleteTap idempotently until it succeeds. 312 var cancel context.CancelFunc
240 continue 313 ctx, cancel = context.WithTimeout(ctx, e.VMTimeout)
241 } 314 defer cancel()
242 _ = e.St.DeleteVM(id) 315 }
243 // Do NOT append to rep.Quarantined — VM is gone. 316
244 } else { 317 var res vmResult
245 // Still in grace: report as quarantined. 318 rec, ok, err := e.St.Get(id)
246 rep.Quarantined = append(rep.Quarantined, quarantinedEntry(rec, grace)) 319 if err != nil {
320 // Record present but unreadable: skip this VM's pass entirely. While
321 // this repeats, the VM's reported row stays frozen at its last known
322 // state, so this log is the ONLY signal that the agent has stopped
323 // reconciling it — a stale row is otherwise indistinguishable from a
324 // healthy one. Not a per-tick flood for a normal condition: a VM with
325 // no record yet returns ok=false and a nil error from Store.Get and
326 // never reaches here.
327 slog.Warn("reconcile: skipping pass, VM record unreadable", "vm_id", id, "err", err)
328 return res, false
329 }
330
331 if a.desired != nil && !a.tombstoned {
332 // Un-delete path: the VM re-appears in desired while still carrying a
333 // stale QuarantinedAt from a previous tombstone. Clear it so the NEXT
334 // delete starts a fresh grace window (not an instant kill from a stale
335 // timestamp).
336 if ok && rec.QuarantinedAt != nil {
337 rec.QuarantinedAt = nil
338 rec.QuarantineTombstoned = false
339 _ = e.St.SaveVM(rec)
247 } 340 }
341 e.reconcileVM(ctx, a.desired, rec, ok, &res)
342 return res, true
248 } 343 }
249 344
250 // ── 4. Level-triggered destroy ack ─────────────────────────────────────── 345 // Absent from desired, or tombstoned: reap it. A tombstoned VM this host
251 // Re-load records after reap pass. Every tombstoned ID with NO local record 346 // has no record of needs no pass at all — the destroy ack covers it.
252 // is acked in destroyed[] every report until the server hard-deletes it. 347 if ok {
253 recs, _ = e.St.LoadVMs() 348 e.reapVM(ctx, id, rec, a.tombstoned, &res)
254 // LoadVMs returns a nil map on a (transient) ReadDir failure. The converge
255 // pass below WRITES into recs (recs[d.VmId]=rec after a durable create); a
256 // write to a nil map panics. Fall back to an empty map so a load blip
257 // degrades to an empty view (as the old read-only use did) instead of
258 // crashing the agent.
259 if recs == nil {
260 recs = map[string]state.Record{}
261 } 349 }
350 return res, true
351 }
352
353 // ackDestroyed appends every tombstoned VM that recs — the caller's record view
354 // — no longer holds a record for. Level-triggered off the most recently accepted
355 // snapshot's delete set, and repeated in every report until the control plane
356 // hard-deletes the VM.
357 //
358 // Step passes the view it dispatched with, read before this tick's destroying
359 // passes ran, so the ack lands one or more ticks AFTER the pass that removed the
360 // record, not in the same tick. Being level-triggered is what makes that fine.
361 func (e *Engine) ackDestroyed(rep *pb.ActualStateReport, tombstoned map[string]bool, recs map[string]state.Record) {
262 for id := range tombstoned { 362 for id := range tombstoned {
263 if _, hasRecord := recs[id]; !hasRecord { 363 if _, hasRecord := recs[id]; !hasRecord {
264 rep.Destroyed = append(rep.Destroyed, id) 364 rep.Destroyed = append(rep.Destroyed, id)
265 } 365 }
266 } 366 }
367 }
267 368
268 // ── 5. Converge pass ───────────────────────────────────────────────────── 369 // reapVM drives ONE local record that is absent from desired or tombstoned:
269 // Drive each active (non-tombstoned) desired VM toward its desired state. 370 // quarantine it (recording the stop intent before shutting down), then destroy
270 for id, d := range desired { 371 // it once its grace expires. Appends to rep as needed.
271 if tombstoned[id] { 372 //
272 continue // tombstoned VMs are handled by reap + destroyed[] 373 // This is the teardown branch of a VM's reconcile pass (see reconcileOne),
374 // split out so a single VM's teardown is expressible on its own.
375 func (e *Engine) reapVM(ctx context.Context, id string, rec state.Record, isTombstoned bool, res *vmResult) {
376 // Being reaped (absent from desired or tombstoned): free its compute
377 // from the admission ledger so a new VM can use it. Idempotent; the IP
378 // reservation is released on destroy by DeleteTap.
379 e.releaseCompute(id)
380
381 now := e.Now()
382
383 if rec.QuarantinedAt == nil {
384 // First time we see this VM needs reaping: enter quarantine.
385 t := now
386 rec.QuarantinedAt = &t
387 rec.QuarantineTombstoned = isTombstoned
388 rec.StopRequested = true // record BEFORE side effects
389 // Fix 5: only Shutdown after the stop intent is durably persisted.
390 // If SaveVM fails, skip Shutdown this cycle — the next reconcile
391 // will retry. This upholds "record stop BEFORE stopping".
392 if err := e.St.SaveVM(rec); err == nil {
393 _ = e.Prov.Shutdown(ctx, id)
273 } 394 }
274 rec, ok := recs[id] 395 } else if isTombstoned && !rec.QuarantineTombstoned {
275 // exists = record present AND create completed. rec.BootID is the sole 396 // Upgrade: vanished quarantine → tombstoned quarantine (shorter grace).
276 // completion witness: create() writes it only after every side effect 397 rec.QuarantineTombstoned = true
277 // (image, tap, disk, seed, boot) has succeeded. Disk presence is NOT a 398 _ = e.St.SaveVM(rec)
278 // witness — create() writes disk.raw mid-sequence, so a create that fails 399 }
279 // after PrepareDisk but before boot leaves a disk on a still-empty BootID. 400
280 // Treating that disk as "exists" would divert the retry to converge(), 401 grace := e.graceFor(rec)
281 // which never rebuilds the disk or seed, and the VM would never recover. 402
282 exists := ok && rec.BootID != "" 403 if now.Sub(*rec.QuarantinedAt) >= grace {
283 if !exists { 404 // Grace expired: destroy the VM.
284 e.create(ctx, d, rep, recs) 405 // NOTE: this may run with an expired pass ctx (watchdog). Safe today
285 } else { 406 // because cloudhv.Kill ignores ctx (SIGKILL via pidfile) and
286 e.converge(ctx, d, rec, rep) 407 // Shutdown falls back to a ctx-free SIGTERM — a future Provisioner
408 // that honors ctx here would skip the destroy until a later tick,
409 // which the level-triggered loop tolerates but delays.
410 _ = e.Prov.Kill(ctx, id)
411 if err := e.Net.DeleteTap(ctx, id); err != nil {
412 // TAP deletion failed — e.g. DeleteTap runs `ip link del` under
413 // ctx and the ctx expired during a SIGTERM shutdown. KEEP the
414 // record so a later tick retries; deleting it here would orphan
415 // the eit-XXXXXXXX interface with nothing left to reap it (agent
416 // restart's EnsureBridge does not sweep orphan taps). Kill already
417 // stopped the guest, so re-entering here next tick (grace still
418 // expired) just retries DeleteTap idempotently until it succeeds.
419 return
287 } 420 }
421 _ = e.St.DeleteVM(id)
422 // Do NOT record a quarantined entry — VM is gone.
423 return
288 } 424 }
425 // Still in grace: report as quarantined.
426 res.quarantined = quarantinedEntry(rec, grace)
427 }
289 428
290 return rep 429 // reconcileVM drives ONE active (non-tombstoned) desired VM toward its desired
430 // state: create it when it does not yet exist, else converge it. rec is this
431 // VM's own persisted record and ok reports whether one exists.
432 //
433 // exists = record present AND create completed. rec.BootID is the sole
434 // completion witness: create() writes it only after every side effect (image,
435 // tap, disk, seed, boot) has succeeded. Disk presence is NOT a witness —
436 // create() writes disk.raw mid-sequence, so a create that fails after
437 // PrepareDisk but before boot leaves a disk on a still-empty BootID. Treating
438 // that disk as "exists" would divert the retry to converge(), which never
439 // rebuilds the disk or seed, and the VM would never recover.
440 func (e *Engine) reconcileVM(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, res *vmResult) {
441 if !ok || rec.BootID == "" {
442 e.create(ctx, d, rec, ok, res)
443 return
444 }
445 e.converge(ctx, d, rec, res)
291 } 446 }
292 447
293 // graceFor returns the quarantine grace period for rec: the shorter 448 // graceFor returns the quarantine grace period for rec: the shorter
@@ -303,7 +458,7 @@ func (e *Engine) graceFor(rec state.Record) time.Duration {
303 // if admitted, reserves the VM's IP. Returns quotaMsg non-empty for a 458 // if admitted, reserves the VM's IP. Returns quotaMsg non-empty for a
304 // NON-TERMINAL quota refusal (nothing is committed, no IP reserved). Otherwise 459 // NON-TERMINAL quota refusal (nothing is committed, no IP reserved). Otherwise
305 // it commits the VM's compute to the ledger BEFORE reserving the IP, so an IP 460 // it commits the VM's compute to the ledger BEFORE reserving the IP, so an IP
306 // reservation failure still counts this VM against a same-step sibling's cap 461 // reservation failure still counts this VM against a same-tick sibling's cap
307 // (parity with the old failCreate publish) — err carries the reservation error. 462 // (parity with the old failCreate publish) — err carries the reservation error.
308 func (e *Engine) admit(vmID string, spec state.VMSpec) (ip, quotaMsg string, err error) { 463 func (e *Engine) admit(vmID string, spec state.VMSpec) (ip, quotaMsg string, err error) {
309 e.mu.Lock() 464 e.mu.Lock()
@@ -319,6 +474,29 @@ func (e *Engine) admit(vmID string, spec state.VMSpec) (ip, quotaMsg string, err
319 return ip, "", err 474 return ip, "", err
320 } 475 }
321 476
477 // acquireCreateSlot takes one of the host's create slots, returning the release
478 // func. It blocks until a slot frees or ctx expires — never longer, so a queued
479 // VM cannot outlive its own pass. MaxConcurrentCreates == 0 disables the
480 // throttle and returns a no-op release.
481 //
482 // This is deliberately NOT the admission gate: quota decides whether a VM may
483 // exist on this host at all and is recorded durably, whereas a create slot is
484 // transient scheduling that shapes how fast the host does the work.
485 func (e *Engine) acquireCreateSlot(ctx context.Context) (func(), error) {
486 if e.MaxConcurrentCreates <= 0 {
487 return func() {}, nil
488 }
489 e.createSlotsOnce.Do(func() {
490 e.createSlots = make(chan struct{}, e.MaxConcurrentCreates)
491 })
492 select {
493 case e.createSlots <- struct{}{}:
494 return func() { <-e.createSlots }, nil
495 case <-ctx.Done():
496 return nil, ctx.Err()
497 }
498 }
499
322 // note commits spec's compute for an existing VM without a quota check. Used for 500 // note commits spec's compute for an existing VM without a quota check. Used for
323 // VMs already created (converge, un-delete re-adoption, startup rebuild) so the 501 // VMs already created (converge, un-delete re-adoption, startup rebuild) so the
324 // ledger always reflects every live VM regardless of how it got there. 502 // ledger always reflects every live VM regardless of how it got there.
@@ -384,19 +562,18 @@ func (e *Engine) quotaCheckLocked(vmID string, spec state.VMSpec) string {
384 return "" 562 return ""
385 } 563 }
386 564
387 // create attempts to create a new VM from desired state d. recs is the tick's 565 // create attempts to create a new VM from desired state d. rec is this VM's own
388 // record map; create reads recs[d.VmId] for this VM's own prior record (retry 566 // prior record (retry budget, existing IP) and ok reports whether one exists.
389 // budget, existing IP). Quota and IP now come from the serialized admission 567 // Quota and IP come from the serialized admission ledger (see admit).
390 // ledger (see admit), not from recs. 568 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, res *vmResult) {
391 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStateReport, recs map[string]state.Record) { 569 // Defensive: never start an attempt (which would burn retry budget) on a
392 rec, ok := recs[d.VmId] 570 // context that is already dead. UNREACHABLE today — a pass context is a
393 571 // fresh context.Background plus VMTimeout (see worker.run), so it cannot
394 // A fired step watchdog is the STEP's failure, not this VM's: don't start 572 // arrive here already expired, and Step's own ctx is threaded nowhere. Kept
395 // an attempt (which would burn retry budget) with a dead context. Report 573 // as cheap insurance for a caller that later threads a cancellable context
396 // and let the next tick — with a fresh budget — do the work. Pinned by 574 // into a pass. Report and let the next tick do the work.
397 // TestWatchdogExpiryDoesNotBurnCreateAttempts.
398 if err := ctx.Err(); err != nil { 575 if err := ctx.Err(); err != nil {
399 addReport(rep, d.VmId, rec.IP, "stopped", "creating", "step timeout: "+err.Error()) 576 res.report(d.VmId, rec.IP, "stopped", "creating", "reconcile aborted: "+err.Error())
400 return 577 return
401 } 578 }
402 579
@@ -413,7 +590,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
413 590
414 // Terminal check: if we've hit MaxCreateAttempts, stop retrying. 591 // Terminal check: if we've hit MaxCreateAttempts, stop retrying.
415 if ok && rec.CreateAttempts >= e.MaxCreateAttempts { 592 if ok && rec.CreateAttempts >= e.MaxCreateAttempts {
416 addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError) 593 res.report(d.VmId, rec.IP, "stopped", "failed", rec.LastError)
417 return 594 return
418 } 595 }
419 596
@@ -425,40 +602,57 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
425 rec.Spec = spec 602 rec.Spec = spec
426 ip, quotaMsg, err := e.admit(d.VmId, spec) 603 ip, quotaMsg, err := e.admit(d.VmId, spec)
427 if quotaMsg != "" { 604 if quotaMsg != "" {
428 addReport(rep, d.VmId, rec.IP, "stopped", "failed", quotaMsg) 605 res.report(d.VmId, rec.IP, "stopped", "failed", quotaMsg)
429 return 606 return
430 } 607 }
431 rec.CreateAttempts++ 608 rec.CreateAttempts++
432 rec.CreatedAt = e.Now() 609 rec.CreatedAt = e.Now()
433 rec.LastError = "" // clear for this attempt 610 rec.LastError = "" // clear for this attempt
434 if err != nil { 611 if err != nil {
435 e.failCreate(ctx, rec, err, rep) 612 e.failCreate(ctx, rec, err, res)
436 return 613 return
437 } 614 }
438 rec.IP = ip 615 rec.IP = ip
439 616
440 // Record BEFORE side effects so a crash is recoverable. 617 // Record BEFORE side effects so a crash is recoverable.
441 if err := e.St.SaveVM(rec); err != nil { 618 if err := e.St.SaveVM(rec); err != nil {
442 e.failCreate(ctx, rec, err, rep) 619 e.failCreate(ctx, rec, err, res)
620 return
621 }
622
623 // Throttle: cap how many VMs on this host may be inside the I/O-heavy part
624 // of create at once. Per-VM workers made these concurrent — N simultaneous
625 // creates mean N image downloads, N qemu-img converts and N multi-GB disk
626 // copies against one device, which can starve the state dir that Step scans
627 // every tick (the heartbeat's one remaining blocking path) and can exhaust
628 // disk on the in-flight temporaries alone. Waiting here is free: the worker
629 // is busy, so it publishes nothing and the report keeps its last-known row.
630 // A wait that outlives VMTimeout fails the attempt, and failCreate REFUNDS a
631 // ctx-expiry failure, so a queued VM never burns retry budget for waiting.
632 // Held through Boot, which is cheap but immediately I/O-heavy in the guest.
633 release, err := e.acquireCreateSlot(ctx)
634 if err != nil {
635 e.failCreate(ctx, rec, err, res)
443 return 636 return
444 } 637 }
638 defer release()
445 639
446 // Resolve base image. 640 // Resolve base image.
447 basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256) 641 basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256)
448 if err != nil { 642 if err != nil {
449 e.failCreate(ctx, rec, err, rep) 643 e.failCreate(ctx, rec, err, res)
450 return 644 return
451 } 645 }
452 646
453 // Create tap device. 647 // Create tap device.
454 if err := e.Net.CreateTap(ctx, d.VmId, rec.IP); err != nil { 648 if err := e.Net.CreateTap(ctx, d.VmId, rec.IP); err != nil {
455 e.failCreate(ctx, rec, err, rep) 649 e.failCreate(ctx, rec, err, res)
456 return 650 return
457 } 651 }
458 652
459 // Prepare disk. 653 // Prepare disk.
460 if err := e.Prov.PrepareDisk(ctx, rec.Spec, basePath); err != nil { 654 if err := e.Prov.PrepareDisk(ctx, rec.Spec, basePath); err != nil {
461 e.failCreate(ctx, rec, err, rep) 655 e.failCreate(ctx, rec, err, res)
462 return 656 return
463 } 657 }
464 658
@@ -473,14 +667,14 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
473 SSHHostKeyPEM: d.SshHostKeyPem, 667 SSHHostKeyPEM: d.SshHostKeyPem,
474 SSHHostCert: d.SshHostCert, 668 SSHHostCert: d.SshHostCert,
475 }); err != nil { 669 }); err != nil {
476 e.failCreate(ctx, rec, err, rep) 670 e.failCreate(ctx, rec, err, res)
477 return 671 return
478 } 672 }
479 673
480 // Boot if desired running. 674 // Boot if desired running.
481 if d.PowerState == "running" { 675 if d.PowerState == "running" {
482 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { 676 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
483 e.failCreate(ctx, rec, err, rep) 677 e.failCreate(ctx, rec, err, res)
484 return 678 return
485 } 679 }
486 } 680 }
@@ -495,7 +689,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
495 if d.PowerState != "running" { 689 if d.PowerState != "running" {
496 power = "stopped" 690 power = "stopped"
497 } 691 }
498 addReport(rep, d.VmId, rec.IP, power, "ready", "") 692 res.report(d.VmId, rec.IP, power, "ready", "")
499 } 693 }
500 694
501 // permanent reports whether err (anywhere in its chain) carries the 695 // permanent reports whether err (anywhere in its chain) carries the
@@ -508,18 +702,18 @@ func permanent(err error) bool {
508 } 702 }
509 703
510 // failCreate records a failed create attempt and appends a report row. When 704 // failCreate records a failed create attempt and appends a report row. When
511 // the step context has expired, the failure belongs to the watchdog, not the 705 // this VM's pass context has expired, the failure belongs to the watchdog, not
512 // VM: the attempt is refunded so wedged steps can never drive a healthy VM to 706 // the VM: the attempt is refunded so a wedged pass can never drive a healthy VM
513 // terminal failed (see TestWatchdogExpiryDoesNotBurnCreateAttempts). A 707 // to terminal failed (see TestWatchdogExpiryDoesNotBurnCreateAttempts). A
514 // Permanent() error spends the whole budget at once — retrying a permanent 708 // Permanent() error spends the whole budget at once — retrying a permanent
515 // misconfiguration only wastes tap/image work across three ticks (pinned by 709 // misconfiguration only wastes tap/image work across three ticks (pinned by
516 // TestPermanentCreateErrorFailsTerminallyInOneAttempt). Ordering: the ctx 710 // TestPermanentCreateErrorFailsTerminallyInOneAttempt). Ordering: the ctx
517 // refund wins over permanence — a permanent error surfacing under an expired 711 // refund wins over permanence — a permanent error surfacing under an expired
518 // ctx is refunded this tick and, being deterministic, terminal-fails on the 712 // ctx is refunded this tick and, being deterministic, terminal-fails on the
519 // next tick's fresh ctx. Keeps the watchdog invariant unconditional. 713 // next tick's fresh ctx. Keeps the watchdog invariant unconditional.
520 func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, rep *pb.ActualStateReport) { 714 func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, res *vmResult) {
521 if ctx.Err() != nil { 715 if ctx.Err() != nil {
522 rec.CreateAttempts-- // refund: the step died mid-operation 716 rec.CreateAttempts-- // refund: this VM's pass died mid-operation
523 } else if permanent(err) { 717 } else if permanent(err) {
524 rec.CreateAttempts = e.MaxCreateAttempts // terminal now; retry cannot succeed 718 rec.CreateAttempts = e.MaxCreateAttempts // terminal now; retry cannot succeed
525 } 719 }
@@ -530,22 +724,22 @@ func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, re
530 if rec.CreateAttempts >= e.MaxCreateAttempts { 724 if rec.CreateAttempts >= e.MaxCreateAttempts {
531 phase = "failed" 725 phase = "failed"
532 } 726 }
533 addReport(rep, rec.Spec.VMID, rec.IP, "stopped", phase, rec.LastError) 727 res.report(rec.Spec.VMID, rec.IP, "stopped", phase, rec.LastError)
534 } 728 }
535 729
536 // failConverge is the shared epilogue for the converge restart/boot paths: 730 // failConverge is the shared epilogue for the converge restart/boot paths:
537 // persist err on the record and report it stopped/failed. Unlike failCreate 731 // persist err on the record and report it stopped/failed. Unlike failCreate
538 // there is no create-attempt budget — a converge (already-created VM) failure 732 // there is no create-attempt budget — a converge (already-created VM) failure
539 // is terminal-for-this-tick and reported failed immediately. 733 // is terminal-for-this-tick and reported failed immediately.
540 func (e *Engine) failConverge(rec state.Record, err error, rep *pb.ActualStateReport) { 734 func (e *Engine) failConverge(rec state.Record, err error, res *vmResult) {
541 rec.LastError = err.Error() 735 rec.LastError = err.Error()
542 _ = e.St.SaveVM(rec) 736 _ = e.St.SaveVM(rec)
543 addReport(rep, rec.Spec.VMID, rec.IP, "stopped", "failed", rec.LastError) 737 res.report(rec.Spec.VMID, rec.IP, "stopped", "failed", rec.LastError)
544 } 738 }
545 739
546 // converge drives an existing VM toward its desired power state, 740 // converge drives an existing VM toward its desired power state,
547 // handling lost detection and restart logic. 741 // handling lost detection and restart logic.
548 func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record, rep *pb.ActualStateReport) { 742 func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record, res *vmResult) {
549 // Keep the ledger reflecting this live VM (covers the un-delete case, where a 743 // Keep the ledger reflecting this live VM (covers the un-delete case, where a
550 // quarantined VM returns to desired after its compute was released). 744 // quarantined VM returns to desired after its compute was released).
551 e.note(d.VmId, rec.Spec) 745 e.note(d.VmId, rec.Spec)
@@ -563,7 +757,7 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
563 errMsg := "ephemeral VM lost" 757 errMsg := "ephemeral VM lost"
564 rec.LastError = errMsg 758 rec.LastError = errMsg
565 _ = e.St.SaveVM(rec) 759 _ = e.St.SaveVM(rec)
566 addReport(rep, d.VmId, rec.IP, "stopped", "failed", errMsg) 760 res.report(d.VmId, rec.IP, "stopped", "failed", errMsg)
567 return 761 return
568 } 762 }
569 763
@@ -574,24 +768,24 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
574 // ITS message — letting Boot fail instead yields an illegible 768 // ITS message — letting Boot fail instead yields an illegible
575 // cloud-hypervisor error for the same root cause. 769 // cloud-hypervisor error for the same root cause.
576 if err := e.Net.CreateTap(ctx, d.VmId, rec.IP); err != nil { 770 if err := e.Net.CreateTap(ctx, d.VmId, rec.IP); err != nil {
577 e.failConverge(rec, err, rep) 771 e.failConverge(rec, err, res)
578 return 772 return
579 } 773 }
580 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { 774 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
581 e.failConverge(rec, err, rep) 775 e.failConverge(rec, err, res)
582 return 776 return
583 } 777 }
584 rec.BootID = bootID 778 rec.BootID = bootID
585 rec.StopRequested = false 779 rec.StopRequested = false
586 rec.LastError = "" // Fix 3: clear stale error on successful restart 780 rec.LastError = "" // Fix 3: clear stale error on successful restart
587 _ = e.St.SaveVM(rec) 781 _ = e.St.SaveVM(rec)
588 addReport(rep, d.VmId, rec.IP, "running", "ready", "") 782 res.report(d.VmId, rec.IP, "running", "ready", "")
589 } else { 783 } else {
590 // Persistent + desired stopped: update boot ID, mark stop recorded. 784 // Persistent + desired stopped: update boot ID, mark stop recorded.
591 rec.BootID = bootID 785 rec.BootID = bootID
592 rec.StopRequested = true 786 rec.StopRequested = true
593 _ = e.St.SaveVM(rec) 787 _ = e.St.SaveVM(rec)
594 addReport(rep, d.VmId, rec.IP, "stopped", "ready", "") 788 res.report(d.VmId, rec.IP, "stopped", "ready", "")
595 } 789 }
596 return 790 return
597 } 791 }
@@ -600,13 +794,13 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
600 if d.PowerState == "running" && !running { 794 if d.PowerState == "running" && !running {
601 // Start the VM. 795 // Start the VM.
602 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { 796 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
603 e.failConverge(rec, err, rep) 797 e.failConverge(rec, err, res)
604 return 798 return
605 } 799 }
606 rec.StopRequested = false 800 rec.StopRequested = false
607 rec.LastError = "" // Fix 3: clear stale error on successful boot 801 rec.LastError = "" // Fix 3: clear stale error on successful boot
608 _ = e.St.SaveVM(rec) 802 _ = e.St.SaveVM(rec)
609 addReport(rep, d.VmId, rec.IP, "running", "ready", "") 803 res.report(d.VmId, rec.IP, "running", "ready", "")
610 } else if d.PowerState == "stopped" && running { 804 } else if d.PowerState == "stopped" && running {
611 // Stop the VM. Record stop BEFORE side effects so a crash between 805 // Stop the VM. Record stop BEFORE side effects so a crash between
612 // SaveVM and Shutdown is recoverable (the persisted StopRequested 806 // SaveVM and Shutdown is recoverable (the persisted StopRequested
@@ -618,11 +812,11 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
618 if err := e.St.SaveVM(rec); err != nil { 812 if err := e.St.SaveVM(rec); err != nil {
619 // Cannot durably record the stop intent; skip Shutdown this cycle. 813 // Cannot durably record the stop intent; skip Shutdown this cycle.
620 // The next reconcile will retry once the store recovers. 814 // The next reconcile will retry once the store recovers.
621 addReport(rep, d.VmId, rec.IP, "running", "failed", err.Error()) 815 res.report(d.VmId, rec.IP, "running", "failed", err.Error())
622 return 816 return
623 } 817 }
624 _ = e.Prov.Shutdown(ctx, d.VmId) 818 _ = e.Prov.Shutdown(ctx, d.VmId)
625 addReport(rep, d.VmId, rec.IP, "stopped", "ready", "") 819 res.report(d.VmId, rec.IP, "stopped", "ready", "")
626 } else { 820 } else {
627 // Already at desired state. 821 // Already at desired state.
628 power := "stopped" 822 power := "stopped"
@@ -631,12 +825,12 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
631 } 825 }
632 // Preserve last error in the report field but phase stays ready 826 // Preserve last error in the report field but phase stays ready
633 // (the VM is converged; the error is informational history). 827 // (the VM is converged; the error is informational history).
634 addReport(rep, d.VmId, rec.IP, power, "ready", rec.LastError) 828 res.report(d.VmId, rec.IP, power, "ready", rec.LastError)
635 } 829 }
636 } 830 }
637 831
638 // quarantinedEntry builds a QuarantinedVM proto from a record and its grace 832 // quarantinedEntry builds a QuarantinedVM proto from a record and its grace
639 // duration. Used by both the reap pass and the fence-path report (Fix 4) so 833 // duration. Used by both reapVM and the fence-path report (Fix 4) so
640 // the JSON shape is identical in both places. 834 // the JSON shape is identical in both places.
641 func quarantinedEntry(rec state.Record, grace time.Duration) *pb.QuarantinedVM { 835 func quarantinedEntry(rec state.Record, grace time.Duration) *pb.QuarantinedVM {
642 specJSON, _ := json.Marshal(rec.Spec) 836 specJSON, _ := json.Marshal(rec.Spec)
@@ -649,17 +843,56 @@ func quarantinedEntry(rec state.Record, grace time.Duration) *pb.QuarantinedVM {
649 } 843 }
650 } 844 }
651 845
652 // addReport appends one ActualVM row to the report. It centralizes the 846 // vmResult is ONE VM's contribution to the host report: at most one actual-VM
653 // construction repeated across the create/converge/fence paths. pb.ActualVM has 847 // row, or one quarantined entry. Per-VM reconcile writes here instead of
654 // exactly these five fields; unset values are the proto zero-value "". 848 // appending straight into the shared report, so a single VM's output stands on
655 func addReport(rep *pb.ActualStateReport, vmID, ip, power, phase, lastError string) { 849 // its own — which is what lets a per-VM worker publish it independently.
656 rep.Vms = append(rep.Vms, &pb.ActualVM{ 850 type vmResult struct {
851 vm *pb.ActualVM
852 quarantined *pb.QuarantinedVM
853 }
854
855 // report records this VM's actual row. A VM contributes at most one row, so a
856 // later call in the same reconcile replaces an earlier one.
857 func (r *vmResult) report(vmID, ip, power, phase, lastError string) {
858 r.vm = newActualVM(vmID, ip, power, phase, lastError)
859 }
860
861 // clone returns a deep copy, so the caller's report owns its rows outright.
862 // A worker publishes a result once and then keeps serving it until its next
863 // pass ends, so without this every report taken in between aliases the same
864 // protos — see collect.
865 func (r vmResult) clone() vmResult {
866 out := r
867 if r.vm != nil {
868 out.vm = proto.Clone(r.vm).(*pb.ActualVM)
869 }
870 if r.quarantined != nil {
871 out.quarantined = proto.Clone(r.quarantined).(*pb.QuarantinedVM)
872 }
873 return out
874 }
875
876 // merge folds this VM's result into the host report.
877 func (r *vmResult) merge(rep *pb.ActualStateReport) {
878 if r.vm != nil {
879 rep.Vms = append(rep.Vms, r.vm)
880 }
881 if r.quarantined != nil {
882 rep.Quarantined = append(rep.Quarantined, r.quarantined)
883 }
884 }
885
886 // newActualVM builds one ActualVM row. pb.ActualVM has exactly these five
887 // fields; unset values are the proto zero-value "".
888 func newActualVM(vmID, ip, power, phase, lastError string) *pb.ActualVM {
889 return &pb.ActualVM{
657 VmId: vmID, 890 VmId: vmID,
658 Ip: ip, 891 Ip: ip,
659 Power: power, 892 Power: power,
660 Phase: phase, 893 Phase: phase,
661 LastError: lastError, 894 LastError: lastError,
662 }) 895 }
663 } 896 }
664 897
665 // joinCALines renders the tenant user-CA set into the multi-line content of the 898 // joinCALines renders the tenant user-CA set into the multi-line content of the
internal/agent/reconcile/reconcile_test.go
Old New
@@ -5,6 +5,7 @@ import (
5 "errors" 5 "errors"
6 "os" 6 "os"
7 "path/filepath" 7 "path/filepath"
8 "sync"
8 "testing" 9 "testing"
9 "time" 10 "time"
10 11
@@ -18,7 +19,12 @@ import (
18 19
19 // ---- fakes ---- 20 // ---- fakes ----
20 21
22 // fakeProv records provisioner calls. Per-VM workers call it from several
23 // goroutines at once, so every method takes mu. Test-goroutine reads of the
24 // recorded slices are deliberately unguarded: they happen after f.step()'s
25 // waitIdle, which establishes happens-before against every worker's last write.
21 type fakeProv struct { 26 type fakeProv struct {
27 mu sync.Mutex
22 running map[string]bool 28 running map[string]bool
23 prepCalls int // total PrepareDisk invocations, including failed ones 29 prepCalls int // total PrepareDisk invocations, including failed ones
24 prepared []string 30 prepared []string
@@ -32,6 +38,8 @@ type fakeProv struct {
32 func newFakeProv() *fakeProv { return &fakeProv{running: map[string]bool{}} } 38 func newFakeProv() *fakeProv { return &fakeProv{running: map[string]bool{}} }
33 39
34 func (f *fakeProv) PrepareDisk(_ context.Context, s state.VMSpec, _ string) error { 40 func (f *fakeProv) PrepareDisk(_ context.Context, s state.VMSpec, _ string) error {
41 f.mu.Lock()
42 defer f.mu.Unlock()
35 f.prepCalls++ // counted BEFORE the error short-circuit: total invocations 43 f.prepCalls++ // counted BEFORE the error short-circuit: total invocations
36 if f.prepErr != nil { 44 if f.prepErr != nil {
37 return f.prepErr 45 return f.prepErr
@@ -40,6 +48,8 @@ func (f *fakeProv) PrepareDisk(_ context.Context, s state.VMSpec, _ string) erro
40 return nil 48 return nil
41 } 49 }
42 func (f *fakeProv) Boot(_ context.Context, id string, _ state.VMSpec) error { 50 func (f *fakeProv) Boot(_ context.Context, id string, _ state.VMSpec) error {
51 f.mu.Lock()
52 defer f.mu.Unlock()
43 if f.bootErr != nil { 53 if f.bootErr != nil {
44 err := f.bootErr 54 err := f.bootErr
45 f.bootErr = nil // one-shot: clear after first use 55 f.bootErr = nil // one-shot: clear after first use
@@ -50,18 +60,30 @@ func (f *fakeProv) Boot(_ context.Context, id string, _ state.VMSpec) error {
50 return nil 60 return nil
51 } 61 }
52 func (f *fakeProv) Shutdown(_ context.Context, id string) error { 62 func (f *fakeProv) Shutdown(_ context.Context, id string) error {
63 f.mu.Lock()
64 defer f.mu.Unlock()
53 f.shutdown = append(f.shutdown, id) 65 f.shutdown = append(f.shutdown, id)
54 f.running[id] = false 66 f.running[id] = false
55 return nil 67 return nil
56 } 68 }
57 func (f *fakeProv) Kill(_ context.Context, id string) error { 69 func (f *fakeProv) Kill(_ context.Context, id string) error {
70 f.mu.Lock()
71 defer f.mu.Unlock()
58 f.killed = append(f.killed, id) 72 f.killed = append(f.killed, id)
59 f.running[id] = false 73 f.running[id] = false
60 return nil 74 return nil
61 } 75 }
62 func (f *fakeProv) Running(id string) bool { return f.running[id] } 76 func (f *fakeProv) Running(id string) bool {
77 f.mu.Lock()
78 defer f.mu.Unlock()
79 return f.running[id]
80 }
63 81
82 // fakeNet records host-networking calls. Guarded for the same reason as
83 // fakeProv: concurrent per-VM workers. ReserveIP is additionally called under
84 // Engine.mu (the admission gate), so its allocation stays serialized either way.
64 type fakeNet struct { 85 type fakeNet struct {
86 mu sync.Mutex
65 taps []string 87 taps []string
66 deleted []string 88 deleted []string
67 cidr string 89 cidr string
@@ -70,10 +92,14 @@ type fakeNet struct {
70 } 92 }
71 93
72 func (f *fakeNet) CreateTap(_ context.Context, vmID, ip string) error { 94 func (f *fakeNet) CreateTap(_ context.Context, vmID, ip string) error {
95 f.mu.Lock()
96 defer f.mu.Unlock()
73 f.taps = append(f.taps, vmID) 97 f.taps = append(f.taps, vmID)
74 return nil 98 return nil
75 } 99 }
76 func (f *fakeNet) DeleteTap(_ context.Context, vmID string) error { 100 func (f *fakeNet) DeleteTap(_ context.Context, vmID string) error {
101 f.mu.Lock()
102 defer f.mu.Unlock()
77 f.deleted = append(f.deleted, vmID) 103 f.deleted = append(f.deleted, vmID)
78 return nil 104 return nil
79 } 105 }
@@ -82,6 +108,8 @@ func (f *fakeNet) DeleteTap(_ context.Context, vmID string) error {
82 // from the CIDR over the set of already-reserved ones, so reconcile tests 108 // from the CIDR over the set of already-reserved ones, so reconcile tests
83 // exercise identical addressing through the seam. 109 // exercise identical addressing through the seam.
84 func (f *fakeNet) ReserveIP(vmID string) (string, error) { 110 func (f *fakeNet) ReserveIP(vmID string) (string, error) {
111 f.mu.Lock()
112 defer f.mu.Unlock()
85 if f.reserveErr != nil { 113 if f.reserveErr != nil {
86 err := f.reserveErr 114 err := f.reserveErr
87 f.reserveErr = nil // one-shot 115 f.reserveErr = nil // one-shot
@@ -133,9 +161,32 @@ func setup(t *testing.T) *fixture {
133 VanishGrace: time.Hour, 161 VanishGrace: time.Hour,
134 MaxCreateAttempts: 3, 162 MaxCreateAttempts: 3,
135 } 163 }
164 t.Cleanup(f.eng.Stop)
136 return f 165 return f
137 } 166 }
138 167
168 // step drives ONE reconcile tick to completion: dispatch, wait for every
169 // worker's pass to finish, then aggregate. Production never waits — not
170 // blocking on workers is the point — but a test has to observe a tick before it
171 // can assert on it. A fenced snapshot dispatches nothing, so its report is
172 // returned as-is.
173 func (f *fixture) step(s *pb.DesiredStateSnapshot) *pb.ActualStateReport {
174 rep := f.eng.Step(context.Background(), s)
175 if rep.FenceViolation {
176 return rep
177 }
178 f.eng.manager().waitIdle()
179 return f.aggregateNow(s.Epoch)
180 }
181
182 // aggregateNow re-reads records and builds the report for epoch, the way Step
183 // does after its dispatch. Tests call it after waitIdle so the ack sees records
184 // the just-finished passes have already changed.
185 func (f *fixture) aggregateNow(epoch uint64) *pb.ActualStateReport {
186 recs, _ := f.st.LoadVMs()
187 return f.eng.aggregate(epoch, recs)
188 }
189
139 func snap(epoch uint64, vms ...*pb.VMDesired) *pb.DesiredStateSnapshot { 190 func snap(epoch uint64, vms ...*pb.VMDesired) *pb.DesiredStateSnapshot {
140 return &pb.DesiredStateSnapshot{Epoch: epoch, Vms: vms} 191 return &pb.DesiredStateSnapshot{Epoch: epoch, Vms: vms}
141 } 192 }
@@ -166,7 +217,7 @@ func findVM(rep *pb.ActualStateReport, id string) *pb.ActualVM {
166 217
167 func TestCreateAllocatesIPPreparesAndBoots(t *testing.T) { 218 func TestCreateAllocatesIPPreparesAndBoots(t *testing.T) {
168 f := setup(t) 219 f := setup(t)
169 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 220 rep := f.step(snap(1, vm("vm1")))
170 assert.Equal(t, []string{"vm1"}, f.prov.prepared) 221 assert.Equal(t, []string{"vm1"}, f.prov.prepared)
171 assert.Equal(t, []string{"vm1"}, f.prov.booted) 222 assert.Equal(t, []string{"vm1"}, f.prov.booted)
172 av := findVM(rep, "vm1") 223 av := findVM(rep, "vm1")
@@ -199,7 +250,7 @@ func TestIncompleteCreateWithDiskPresentIsRetried(t *testing.T) {
199 _, statErr := os.Stat(f.st.DiskPath(id)) 250 _, statErr := os.Stat(f.st.DiskPath(id))
200 require.NoError(t, statErr, "precondition: disk present") 251 require.NoError(t, statErr, "precondition: disk present")
201 252
202 rep := f.eng.Step(context.Background(), snap(1, vm(id))) 253 rep := f.step(snap(1, vm(id)))
203 254
204 assert.Equal(t, []string{id}, f.prov.prepared, "must re-run create (PrepareDisk), not converge") 255 assert.Equal(t, []string{id}, f.prov.prepared, "must re-run create (PrepareDisk), not converge")
205 assert.Equal(t, []string{id}, f.prov.booted) 256 assert.Equal(t, []string{id}, f.prov.booted)
@@ -208,29 +259,51 @@ func TestIncompleteCreateWithDiskPresentIsRetried(t *testing.T) {
208 assert.Equal(t, "ready", av.Phase) 259 assert.Equal(t, "ready", av.Phase)
209 } 260 }
210 261
211 // TestCreateSurvivesRecordLoadFailure guards against the nil-map panic: when 262 // TestCreateSurvivesRecordLoadFailure pins that a create still succeeds when
212 // LoadVMs errors (transient ReadDir failure) it returns a nil map, and the 263 // the store cannot list records: a transient ReadDir failure makes LoadVMs
213 // converge pass WRITES into that map (recs[d.VmId]=rec) after a durable create. 264 // return a nil map, and the tick must degrade to "no local records" rather than
214 // A write to a nil map panics — crashing the agent where the old read-only use 265 // panic or strand the VM. We force the failure by removing the vms/ dir; SaveVM
215 // merely degraded. We force the failure by removing the vms/ dir; SaveVM 266 // re-creates vms/<id> via MkdirAll, so the create commits regardless.
216 // re-creates vms/<id> via MkdirAll, so the create still commits and the write
217 // path is exercised.
218 func TestCreateSurvivesRecordLoadFailure(t *testing.T) { 267 func TestCreateSurvivesRecordLoadFailure(t *testing.T) {
219 f := setup(t) 268 f := setup(t)
220 vmsDir := filepath.Dir(f.st.VMDir("placeholder")) 269 vmsDir := filepath.Dir(f.st.VMDir("placeholder"))
221 require.NoError(t, os.RemoveAll(vmsDir)) 270 require.NoError(t, os.RemoveAll(vmsDir))
222 271
223 require.NotPanics(t, func() { 272 require.NotPanics(t, func() {
224 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 273 rep := f.step(snap(1, vm("vm1")))
225 av := findVM(rep, "vm1") 274 av := findVM(rep, "vm1")
226 require.NotNil(t, av) 275 require.NotNil(t, av)
227 assert.Equal(t, "ready", av.Phase) 276 assert.Equal(t, "ready", av.Phase)
228 }) 277 })
229 } 278 }
230 279
280 // TestUnreadableRecordDoesNotRecreateTheVM pins the consequence of Store.Get
281 // telling an unreadable record apart from an absent one. A live VM whose record
282 // cannot be read must NOT be re-created: create() runs PrepareDisk over the disk
283 // its guest is running from and boots a second cloud-hypervisor over the first,
284 // and it is the only path here that corrupts state instead of retrying. The pass
285 // is skipped instead, and the VM keeps its last-known row until the store
286 // recovers.
287 func TestUnreadableRecordDoesNotRecreateTheVM(t *testing.T) {
288 f := setup(t)
289 const id = "vm1"
290
291 rep := f.step(snap(1, vm(id)))
292 require.Equal(t, []string{id}, f.prov.prepared, "precondition: created once")
293 require.Equal(t, "ready", findVM(rep, id).GetPhase())
294
295 require.NoError(t, os.WriteFile(filepath.Join(f.st.VMDir(id), "record.json"), []byte("{not json"), 0o600))
296 f.prov.prepared = nil
297
298 rep = f.step(snap(2, vm(id)))
299
300 assert.Empty(t, f.prov.prepared, "a VM whose record cannot be read must not be re-created")
301 assert.Equal(t, "ready", findVM(rep, id).GetPhase(), "the VM keeps its last-known row")
302 }
303
231 // TestSameTickSiblingCountsFailedCreateAgainstQuota pins that a VM whose create 304 // TestSameTickSiblingCountsFailedCreateAgainstQuota pins that a VM whose create
232 // fails early (here: IP allocation) still has its Spec published into the tick's 305 // fails early (here: IP allocation) still has its Spec committed to the
233 // record map, so a SAME-TICK sibling's quota check counts it. Both VMs are 306 // admission ledger, so a SAME-TICK sibling's quota check counts it. Both VMs are
234 // identical (2 vCPU) under a 3-vCPU cap, so whichever is processed first fails IP 307 // identical (2 vCPU) under a 3-vCPU cap, so whichever is processed first fails IP
235 // allocation and the other must be quota-blocked — NEITHER boots. Before the fix 308 // allocation and the other must be quota-blocked — NEITHER boots. Before the fix
236 // the failed VM was invisible to the sibling, which wrongly booted. 309 // the failed VM was invisible to the sibling, which wrongly booted.
@@ -240,7 +313,7 @@ func TestSameTickSiblingCountsFailedCreateAgainstQuota(t *testing.T) {
240 f.net.reserveErr = assert.AnError 313 f.net.reserveErr = assert.AnError
241 twoVCPU := func(v *pb.VMDesired) { v.Vcpus = 2 } 314 twoVCPU := func(v *pb.VMDesired) { v.Vcpus = 2 }
242 315
243 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", twoVCPU), vm("vm2", twoVCPU))) 316 rep := f.step(snap(1, vm("vm1", twoVCPU), vm("vm2", twoVCPU)))
244 317
245 ready := 0 318 ready := 0
246 for _, v := range rep.Vms { 319 for _, v := range rep.Vms {
@@ -254,8 +327,8 @@ func TestSameTickSiblingCountsFailedCreateAgainstQuota(t *testing.T) {
254 327
255 func TestFenceRefusesLowerEpochWithoutActing(t *testing.T) { 328 func TestFenceRefusesLowerEpochWithoutActing(t *testing.T) {
256 f := setup(t) 329 f := setup(t)
257 f.eng.Step(context.Background(), snap(5, vm("vm1"))) 330 f.step(snap(5, vm("vm1")))
258 rep := f.eng.Step(context.Background(), snap(3)) // restore signature: vm1 missing, lower epoch 331 rep := f.step(snap(3)) // restore signature: vm1 missing, lower epoch
259 assert.True(t, rep.FenceViolation) 332 assert.True(t, rep.FenceViolation)
260 assert.Empty(t, f.prov.killed, "fenced snapshot must trigger no destroys") 333 assert.Empty(t, f.prov.killed, "fenced snapshot must trigger no destroys")
261 assert.Empty(t, f.prov.shutdown) 334 assert.Empty(t, f.prov.shutdown)
@@ -266,10 +339,10 @@ func TestCreateRetryIsBoundedThenTerminalFailed(t *testing.T) {
266 f := setup(t) 339 f := setup(t)
267 f.prov.prepErr = assert.AnError 340 f.prov.prepErr = assert.AnError
268 for i := 0; i < 3; i++ { 341 for i := 0; i < 3; i++ {
269 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 342 f.step(snap(1, vm("vm1")))
270 } 343 }
271 f.prov.prepErr = nil // even if the cause clears... 344 f.prov.prepErr = nil // even if the cause clears...
272 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 345 rep := f.step(snap(1, vm("vm1")))
273 av := findVM(rep, "vm1") 346 av := findVM(rep, "vm1")
274 require.NotNil(t, av) 347 require.NotNil(t, av)
275 assert.Equal(t, "failed", av.Phase) 348 assert.Equal(t, "failed", av.Phase)
@@ -278,11 +351,11 @@ func TestCreateRetryIsBoundedThenTerminalFailed(t *testing.T) {
278 351
279 func TestUserStopIsStoppedNotLost(t *testing.T) { 352 func TestUserStopIsStoppedNotLost(t *testing.T) {
280 f := setup(t) 353 f := setup(t)
281 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 354 f.step(snap(1, vm("vm1")))
282 f.eng.Step(context.Background(), snap(2, vm("vm1", stopped))) 355 f.step(snap(2, vm("vm1", stopped)))
283 assert.Equal(t, []string{"vm1"}, f.prov.shutdown) 356 assert.Equal(t, []string{"vm1"}, f.prov.shutdown)
284 357
285 rep := f.eng.Step(context.Background(), snap(2, vm("vm1", stopped))) 358 rep := f.step(snap(2, vm("vm1", stopped)))
286 av := findVM(rep, "vm1") 359 av := findVM(rep, "vm1")
287 assert.Equal(t, "stopped", av.Power) 360 assert.Equal(t, "stopped", av.Power)
288 assert.NotEqual(t, "failed", av.Phase, "recorded stop request: stopped != lost") 361 assert.NotEqual(t, "failed", av.Phase, "recorded stop request: stopped != lost")
@@ -290,11 +363,11 @@ func TestUserStopIsStoppedNotLost(t *testing.T) {
290 363
291 func TestEphemeralLostOnHostRebootNeverRestarts(t *testing.T) { 364 func TestEphemeralLostOnHostRebootNeverRestarts(t *testing.T) {
292 f := setup(t) 365 f := setup(t)
293 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 366 f.step(snap(1, vm("vm1")))
294 f.boot = "boot-2" // host rebooted 367 f.boot = "boot-2" // host rebooted
295 f.prov.running["vm1"] = false 368 f.prov.running["vm1"] = false
296 f.prov.booted = nil 369 f.prov.booted = nil
297 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 370 rep := f.step(snap(1, vm("vm1")))
298 av := findVM(rep, "vm1") 371 av := findVM(rep, "vm1")
299 assert.Equal(t, "failed", av.Phase) 372 assert.Equal(t, "failed", av.Phase)
300 assert.Contains(t, av.LastError, "ephemeral VM lost") 373 assert.Contains(t, av.LastError, "ephemeral VM lost")
@@ -303,26 +376,26 @@ func TestEphemeralLostOnHostRebootNeverRestarts(t *testing.T) {
303 376
304 func TestPersistentRestartsAfterHostReboot(t *testing.T) { 377 func TestPersistentRestartsAfterHostReboot(t *testing.T) {
305 f := setup(t) 378 f := setup(t)
306 f.eng.Step(context.Background(), snap(1, vm("vm1", persistent))) 379 f.step(snap(1, vm("vm1", persistent)))
307 f.boot = "boot-2" 380 f.boot = "boot-2"
308 f.prov.running["vm1"] = false 381 f.prov.running["vm1"] = false
309 f.prov.booted = nil 382 f.prov.booted = nil
310 f.eng.Step(context.Background(), snap(1, vm("vm1", persistent))) 383 f.step(snap(1, vm("vm1", persistent)))
311 assert.Equal(t, []string{"vm1"}, f.prov.booted, "persistent + desired running: restart") 384 assert.Equal(t, []string{"vm1"}, f.prov.booted, "persistent + desired running: restart")
312 } 385 }
313 386
314 func TestProcessDiedWithoutStopIsLost(t *testing.T) { 387 func TestProcessDiedWithoutStopIsLost(t *testing.T) {
315 f := setup(t) 388 f := setup(t)
316 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 389 f.step(snap(1, vm("vm1")))
317 f.prov.running["vm1"] = false // crashed; no stop request, same boot ID 390 f.prov.running["vm1"] = false // crashed; no stop request, same boot ID
318 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 391 rep := f.step(snap(1, vm("vm1")))
319 assert.Equal(t, "failed", findVM(rep, "vm1").Phase) 392 assert.Equal(t, "failed", findVM(rep, "vm1").Phase)
320 } 393 }
321 394
322 func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) { 395 func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) {
323 f := setup(t) 396 f := setup(t)
324 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 397 f.step(snap(1, vm("vm1")))
325 rep := f.eng.Step(context.Background(), snap(2, tombstoned(vm("vm1")))) 398 rep := f.step(snap(2, tombstoned(vm("vm1"))))
326 require.Len(t, rep.Quarantined, 1) 399 require.Len(t, rep.Quarantined, 1)
327 assert.Equal(t, "vm1", rep.Quarantined[0].VmId) 400 assert.Equal(t, "vm1", rep.Quarantined[0].VmId)
328 assert.NotEmpty(t, rep.Quarantined[0].VmspecJson, "spec travels with quarantine (un-delete after restore)") 401 assert.NotEmpty(t, rep.Quarantined[0].VmspecJson, "spec travels with quarantine (un-delete after restore)")
@@ -330,7 +403,7 @@ func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) {
330 assert.Empty(t, rep.Destroyed, "still in grace") 403 assert.Empty(t, rep.Destroyed, "still in grace")
331 404
332 f.now = f.now.Add(6 * time.Minute) // past TombstoneGrace 405 f.now = f.now.Add(6 * time.Minute) // past TombstoneGrace
333 rep = f.eng.Step(context.Background(), snap(2, tombstoned(vm("vm1")))) 406 rep = f.step(snap(2, tombstoned(vm("vm1"))))
334 assert.Equal(t, []string{"vm1"}, f.prov.killed) 407 assert.Equal(t, []string{"vm1"}, f.prov.killed)
335 assert.Contains(t, rep.Destroyed, "vm1", "destroy ack after grace") 408 assert.Contains(t, rep.Destroyed, "vm1", "destroy ack after grace")
336 recs, _ := f.st.LoadVMs() 409 recs, _ := f.st.LoadVMs()
@@ -341,16 +414,16 @@ func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) {
341 414
342 func TestVanishedWithoutTombstoneGetsLongGrace(t *testing.T) { 415 func TestVanishedWithoutTombstoneGetsLongGrace(t *testing.T) {
343 f := setup(t) 416 f := setup(t)
344 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 417 f.step(snap(1, vm("vm1")))
345 // vm1 absent AND not tombstoned at a HIGHER epoch: the bug signature, long grace. 418 // vm1 absent AND not tombstoned at a HIGHER epoch: the bug signature, long grace.
346 f.eng.Step(context.Background(), snap(2)) 419 f.step(snap(2))
347 f.now = f.now.Add(30 * time.Minute) 420 f.now = f.now.Add(30 * time.Minute)
348 rep := f.eng.Step(context.Background(), snap(2)) 421 rep := f.step(snap(2))
349 assert.Empty(t, f.prov.killed, "vanished VMs get the full VanishGrace (1h)") 422 assert.Empty(t, f.prov.killed, "vanished VMs get the full VanishGrace (1h)")
350 require.Len(t, rep.Quarantined, 1) 423 require.Len(t, rep.Quarantined, 1)
351 424
352 f.now = f.now.Add(31 * time.Minute) 425 f.now = f.now.Add(31 * time.Minute)
353 f.eng.Step(context.Background(), snap(2)) 426 f.step(snap(2))
354 assert.Equal(t, []string{"vm1"}, f.prov.killed) 427 assert.Equal(t, []string{"vm1"}, f.prov.killed)
355 } 428 }
356 429
@@ -358,24 +431,24 @@ func TestDestroyedIsLevelTriggeredForUnknownTombstones(t *testing.T) {
358 f := setup(t) 431 f := setup(t)
359 // Tombstoned VM the agent has no record of (created+deleted while offline, 432 // Tombstoned VM the agent has no record of (created+deleted while offline,
360 // or state dir wiped): ack it EVERY report until the server hard-deletes. 433 // or state dir wiped): ack it EVERY report until the server hard-deletes.
361 rep := f.eng.Step(context.Background(), snap(1, tombstoned(vm("ghost")))) 434 rep := f.step(snap(1, tombstoned(vm("ghost"))))
362 assert.Contains(t, rep.Destroyed, "ghost") 435 assert.Contains(t, rep.Destroyed, "ghost")
363 rep = f.eng.Step(context.Background(), snap(1, tombstoned(vm("ghost")))) 436 rep = f.step(snap(1, tombstoned(vm("ghost"))))
364 assert.Contains(t, rep.Destroyed, "ghost", "repeated until it leaves desired state") 437 assert.Contains(t, rep.Destroyed, "ghost", "repeated until it leaves desired state")
365 } 438 }
366 439
367 // Fix 1: un-quarantine on un-delete so next delete gets a fresh grace window. 440 // Fix 1: un-quarantine on un-delete so next delete gets a fresh grace window.
368 func TestUndeleteClearsQuarantineSoNextDeleteGetsFullGrace(t *testing.T) { 441 func TestUndeleteClearsQuarantineSoNextDeleteGetsFullGrace(t *testing.T) {
369 f := setup(t) 442 f := setup(t)
370 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 443 f.step(snap(1, vm("vm1")))
371 // delete -> quarantined 444 // delete -> quarantined
372 f.eng.Step(context.Background(), snap(2, tombstoned(vm("vm1")))) 445 f.step(snap(2, tombstoned(vm("vm1"))))
373 // un-delete: vm1 back in desired, not tombstoned 446 // un-delete: vm1 back in desired, not tombstoned
374 f.now = f.now.Add(2 * time.Minute) 447 f.now = f.now.Add(2 * time.Minute)
375 f.eng.Step(context.Background(), snap(3, vm("vm1"))) 448 f.step(snap(3, vm("vm1")))
376 // much later, delete again: must get a FRESH grace window, not instant kill 449 // much later, delete again: must get a FRESH grace window, not instant kill
377 f.now = f.now.Add(24 * time.Hour) 450 f.now = f.now.Add(24 * time.Hour)
378 rep := f.eng.Step(context.Background(), snap(4, tombstoned(vm("vm1")))) 451 rep := f.step(snap(4, tombstoned(vm("vm1"))))
379 assert.Empty(t, f.prov.killed, "fresh quarantine window required after un-delete") 452 assert.Empty(t, f.prov.killed, "fresh quarantine window required after un-delete")
380 require.Len(t, rep.Quarantined, 1) 453 require.Len(t, rep.Quarantined, 1)
381 recs, _ := f.st.LoadVMs() 454 recs, _ := f.st.LoadVMs()
@@ -388,13 +461,13 @@ func TestEditedSpecResetsCreateAttempts(t *testing.T) {
388 f := setup(t) 461 f := setup(t)
389 f.prov.prepErr = assert.AnError 462 f.prov.prepErr = assert.AnError
390 for i := 0; i < 3; i++ { 463 for i := 0; i < 3; i++ {
391 f.eng.Step(context.Background(), snap(1, vm("vm1"))) 464 f.step(snap(1, vm("vm1")))
392 } 465 }
393 f.prov.prepErr = nil 466 f.prov.prepErr = nil
394 // user edits the VM (more memory): retry must happen 467 // user edits the VM (more memory): retry must happen
395 edited := vm("vm1") 468 edited := vm("vm1")
396 edited.MemMb = 1024 469 edited.MemMb = 1024
397 rep := f.eng.Step(context.Background(), snap(2, edited)) 470 rep := f.step(snap(2, edited))
398 assert.Equal(t, []string{"vm1"}, f.prov.prepared, "edited spec must reset the attempt budget") 471 assert.Equal(t, []string{"vm1"}, f.prov.prepared, "edited spec must reset the attempt budget")
399 assert.Equal(t, "ready", findVM(rep, "vm1").Phase) 472 assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
400 } 473 }
@@ -406,7 +479,7 @@ func TestEditedSpecResetsCreateAttempts(t *testing.T) {
406 func TestConvergeBootSuccessClearsLastError(t *testing.T) { 479 func TestConvergeBootSuccessClearsLastError(t *testing.T) {
407 f := setup(t) 480 f := setup(t)
408 // Step 1: create succeeds — VM is running, BootID set. 481 // Step 1: create succeeds — VM is running, BootID set.
409 f.eng.Step(context.Background(), snap(1, vm("vm1", persistent))) 482 f.step(snap(1, vm("vm1", persistent)))
410 require.Equal(t, []string{"vm1"}, f.prov.booted) 483 require.Equal(t, []string{"vm1"}, f.prov.booted)
411 484
412 // Step 2: simulate host reboot (boot-2), VM process gone. Converge will try to 485 // Step 2: simulate host reboot (boot-2), VM process gone. Converge will try to
@@ -414,7 +487,7 @@ func TestConvergeBootSuccessClearsLastError(t *testing.T) {
414 f.boot = "boot-2" 487 f.boot = "boot-2"
415 f.prov.running["vm1"] = false 488 f.prov.running["vm1"] = false
416 f.prov.bootErr = assert.AnError 489 f.prov.bootErr = assert.AnError
417 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", persistent))) 490 rep := f.step(snap(1, vm("vm1", persistent)))
418 av := findVM(rep, "vm1") 491 av := findVM(rep, "vm1")
419 require.NotNil(t, av) 492 require.NotNil(t, av)
420 require.Equal(t, "failed", av.Phase, "boot failure must report failed") 493 require.Equal(t, "failed", av.Phase, "boot failure must report failed")
@@ -422,7 +495,7 @@ func TestConvergeBootSuccessClearsLastError(t *testing.T) {
422 495
423 // Step 3: bootErr is cleared (one-shot). Converge retries and succeeds. 496 // Step 3: bootErr is cleared (one-shot). Converge retries and succeeds.
424 // LastError must be cleared from both report and persisted record. 497 // LastError must be cleared from both report and persisted record.
425 rep = f.eng.Step(context.Background(), snap(1, vm("vm1", persistent))) 498 rep = f.step(snap(1, vm("vm1", persistent)))
426 av = findVM(rep, "vm1") 499 av = findVM(rep, "vm1")
427 require.NotNil(t, av) 500 require.NotNil(t, av)
428 assert.Equal(t, "ready", av.Phase) 501 assert.Equal(t, "ready", av.Phase)
@@ -435,15 +508,15 @@ func TestConvergeBootSuccessClearsLastError(t *testing.T) {
435 func TestFenceReportIncludesQuarantinedVMs(t *testing.T) { 508 func TestFenceReportIncludesQuarantinedVMs(t *testing.T) {
436 f := setup(t) 509 f := setup(t)
437 // Create a VM, then quarantine it. 510 // Create a VM, then quarantine it.
438 f.eng.Step(context.Background(), snap(5, vm("vm1"))) 511 f.step(snap(5, vm("vm1")))
439 f.eng.Step(context.Background(), snap(6, tombstoned(vm("vm1")))) 512 f.step(snap(6, tombstoned(vm("vm1"))))
440 // Confirm it is quarantined. 513 // Confirm it is quarantined.
441 recs, _ := f.st.LoadVMs() 514 recs, _ := f.st.LoadVMs()
442 require.Contains(t, recs, "vm1") 515 require.Contains(t, recs, "vm1")
443 require.NotNil(t, recs["vm1"].QuarantinedAt) 516 require.NotNil(t, recs["vm1"].QuarantinedAt)
444 517
445 // Send a lower-epoch snapshot: fence path must fire and include quarantined entry. 518 // Send a lower-epoch snapshot: fence path must fire and include quarantined entry.
446 rep := f.eng.Step(context.Background(), snap(3)) 519 rep := f.step(snap(3))
447 assert.True(t, rep.FenceViolation) 520 assert.True(t, rep.FenceViolation)
448 require.Len(t, rep.Quarantined, 1, "fence report must include quarantined VMs") 521 require.Len(t, rep.Quarantined, 1, "fence report must include quarantined VMs")
449 assert.Equal(t, "vm1", rep.Quarantined[0].VmId) 522 assert.Equal(t, "vm1", rep.Quarantined[0].VmId)
@@ -451,21 +524,21 @@ func TestFenceReportIncludesQuarantinedVMs(t *testing.T) {
451 assert.Nil(t, findVM(rep, "vm1"), "quarantined VM must not appear in Vms on fence path") 524 assert.Nil(t, findVM(rep, "vm1"), "quarantined VM must not appear in Vms on fence path")
452 } 525 }
453 526
454 // TestStepTimeoutBoundsSlowOperations pins the Step watchdog: a wedged 527 // TestVMTimeoutBoundsSlowOperations pins the per-VM watchdog: a wedged
455 // operation inside Step (here: an image fetch that never returns until its 528 // operation (here: an image fetch that never returns until its context is
456 // context is cancelled) must not block the reconcile loop forever. With 529 // cancelled) must not block that VM's reconcile forever. With VMTimeout set,
457 // StepTimeout set, Step's context expires, the create fails with the ctx 530 // the pass's context expires, the create fails with the ctx error, and the
458 // error, and Step returns so the next tick can retry. 531 // tick completes so the next one can retry.
459 func TestStepTimeoutBoundsSlowOperations(t *testing.T) { 532 func TestVMTimeoutBoundsSlowOperations(t *testing.T) {
460 f := setup(t) 533 f := setup(t)
461 f.eng.StepTimeout = 50 * time.Millisecond 534 f.eng.VMTimeout = 50 * time.Millisecond
462 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 535 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
463 <-ctx.Done() // wedged until the watchdog fires 536 <-ctx.Done() // wedged until the watchdog fires
464 return "", ctx.Err() 537 return "", ctx.Err()
465 } 538 }
466 539
467 done := make(chan *pb.ActualStateReport, 1) 540 done := make(chan *pb.ActualStateReport, 1)
468 go func() { done <- f.eng.Step(context.Background(), snap(1, vm("vm1"))) }() 541 go func() { done <- f.step(snap(1, vm("vm1"))) }()
469 542
470 select { 543 select {
471 case rep := <-done: 544 case rep := <-done:
@@ -474,41 +547,41 @@ func TestStepTimeoutBoundsSlowOperations(t *testing.T) {
474 assert.Equal(t, "creating", row.Phase, "first failed attempt stays in creating (retry budget)") 547 assert.Equal(t, "creating", row.Phase, "first failed attempt stays in creating (retry budget)")
475 assert.Contains(t, row.LastError, "context deadline exceeded") 548 assert.Contains(t, row.LastError, "context deadline exceeded")
476 case <-time.After(2 * time.Second): 549 case <-time.After(2 * time.Second):
477 t.Fatal("Step did not return: a wedged operation blocked the reconcile loop (no watchdog)") 550 t.Fatal("the VM's pass never finished: a wedged operation was not bounded by VMTimeout")
478 } 551 }
479 } 552 }
480 553
481 // TestStepTimeoutZeroDisablesWatchdog: the zero value must not impose any 554 // TestVMTimeoutZeroDisablesWatchdog: the zero value must not impose any
482 // deadline (all pre-existing behavior and tests rely on unbounded Step). 555 // deadline (all pre-existing behavior and tests rely on an unbounded pass).
483 func TestStepTimeoutZeroDisablesWatchdog(t *testing.T) { 556 func TestVMTimeoutZeroDisablesWatchdog(t *testing.T) {
484 f := setup(t) 557 f := setup(t)
485 sawDeadline := false 558 sawDeadline := false
486 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 559 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
487 _, sawDeadline = ctx.Deadline() 560 _, sawDeadline = ctx.Deadline()
488 return "/cache/x.raw", nil 561 return "/cache/x.raw", nil
489 } 562 }
490 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 563 rep := f.step(snap(1, vm("vm1")))
491 require.NotNil(t, findVM(rep, "vm1")) 564 require.NotNil(t, findVM(rep, "vm1"))
492 assert.False(t, sawDeadline, "StepTimeout==0 must not set a deadline") 565 assert.False(t, sawDeadline, "VMTimeout==0 must not set a deadline")
493 } 566 }
494 567
495 // TestWatchdogExpiryDoesNotBurnCreateAttempts pins that a fired step watchdog 568 // TestWatchdogExpiryDoesNotBurnCreateAttempts pins that a fired watchdog is the
496 // is the STEP's failure, not any VM's: records must not lose retry budget when 569 // WATCHDOG's failure, not the VM's: a record must not lose retry budget when
497 // the ctx has expired (three wedged steps would otherwise terminal-fail 570 // its pass's ctx expired. Each VM is bounded independently, so a wedged VM can
498 // perfectly healthy VMs — including ones that never got a turn because an 571 // no longer starve a sibling of its turn either.
499 // earlier VM in the randomized map order consumed the whole budget).
500 func TestWatchdogExpiryDoesNotBurnCreateAttempts(t *testing.T) { 572 func TestWatchdogExpiryDoesNotBurnCreateAttempts(t *testing.T) {
501 f := setup(t) 573 f := setup(t)
502 f.eng.StepTimeout = 50 * time.Millisecond 574 f.eng.VMTimeout = 50 * time.Millisecond
503 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) { 575 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
504 <-ctx.Done() // every fetch wedges until the watchdog fires 576 <-ctx.Done() // every fetch wedges until the watchdog fires
505 return "", ctx.Err() 577 return "", ctx.Err()
506 } 578 }
507 579
508 _ = f.eng.Step(context.Background(), snap(1, vm("vm1"), vm("vm2"))) 580 _ = f.step(snap(1, vm("vm1"), vm("vm2")))
509 581
510 recs, err := f.st.LoadVMs() 582 recs, err := f.st.LoadVMs()
511 require.NoError(t, err) 583 require.NoError(t, err)
584 require.Len(t, recs, 2, "both VMs got their own bounded turn")
512 for id, rec := range recs { 585 for id, rec := range recs {
513 assert.Zero(t, rec.CreateAttempts, 586 assert.Zero(t, rec.CreateAttempts,
514 "vm %s: watchdog expiry must not burn the retry budget", id) 587 "vm %s: watchdog expiry must not burn the retry budget", id)
@@ -529,14 +602,14 @@ func TestPermanentCreateErrorFailsTerminallyInOneAttempt(t *testing.T) {
529 f := setup(t) 602 f := setup(t)
530 f.prov.prepErr = permErr{"disk_gb 1 is smaller than base image"} 603 f.prov.prepErr = permErr{"disk_gb 1 is smaller than base image"}
531 604
532 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 605 rep := f.step(snap(1, vm("vm1")))
533 row := findVM(rep, "vm1") 606 row := findVM(rep, "vm1")
534 require.NotNil(t, row) 607 require.NotNil(t, row)
535 assert.Equal(t, "failed", row.Phase, "permanent error must be terminal on attempt 1") 608 assert.Equal(t, "failed", row.Phase, "permanent error must be terminal on attempt 1")
536 assert.Contains(t, row.LastError, "smaller than base image") 609 assert.Contains(t, row.LastError, "smaller than base image")
537 610
538 require.Equal(t, 1, f.prov.prepCalls, "exactly one PrepareDisk invocation") 611 require.Equal(t, 1, f.prov.prepCalls, "exactly one PrepareDisk invocation")
539 rep = f.eng.Step(context.Background(), snap(1, vm("vm1"))) 612 rep = f.step(snap(1, vm("vm1")))
540 row = findVM(rep, "vm1") 613 row = findVM(rep, "vm1")
541 require.NotNil(t, row) 614 require.NotNil(t, row)
542 assert.Equal(t, "failed", row.Phase) 615 assert.Equal(t, "failed", row.Phase)
@@ -550,7 +623,7 @@ func TestPermanentCreateErrorFailsTerminallyInOneAttempt(t *testing.T) {
550 // randomized, so this must hold regardless of which VM is created first. 623 // randomized, so this must hold regardless of which VM is created first.
551 func TestTwoVMsCreatedInOneStepGetDistinctIPs(t *testing.T) { 624 func TestTwoVMsCreatedInOneStepGetDistinctIPs(t *testing.T) {
552 f := setup(t) 625 f := setup(t)
553 f.eng.Step(context.Background(), snap(1, vm("vm1"), vm("vm2"))) 626 f.step(snap(1, vm("vm1"), vm("vm2")))
554 recs, err := f.st.LoadVMs() 627 recs, err := f.st.LoadVMs()
555 require.NoError(t, err) 628 require.NoError(t, err)
556 require.Contains(t, recs, "vm1") 629 require.Contains(t, recs, "vm1")
@@ -570,7 +643,7 @@ func TestTwoVMsCreatedInOneStepGetDistinctIPs(t *testing.T) {
570 func TestSecondVMInOneStepBustingCapIsBlocked(t *testing.T) { 643 func TestSecondVMInOneStepBustingCapIsBlocked(t *testing.T) {
571 f := setup(t) 644 f := setup(t)
572 f.eng.MaxVCPUs = 3 645 f.eng.MaxVCPUs = 3
573 rep := f.eng.Step(context.Background(), snap(1, 646 rep := f.step(snap(1,
574 vm("vm1", withRes(2, 512, 5)), 647 vm("vm1", withRes(2, 512, 5)),
575 vm("vm2", withRes(2, 512, 5)))) 648 vm("vm2", withRes(2, 512, 5))))
576 require.Len(t, f.prov.booted, 1, "exactly one of two cap-busting VMs may boot in one tick") 649 require.Len(t, f.prov.booted, 1, "exactly one of two cap-busting VMs may boot in one tick")
@@ -593,12 +666,12 @@ func TestTransientCreateErrorStillRetries(t *testing.T) {
593 f.prov.prepErr = errors.New("cp: reflink failed") // unmarked -> transient 666 f.prov.prepErr = errors.New("cp: reflink failed") // unmarked -> transient
594 667
595 for i := 1; i < f.eng.MaxCreateAttempts; i++ { 668 for i := 1; i < f.eng.MaxCreateAttempts; i++ {
596 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 669 rep := f.step(snap(1, vm("vm1")))
597 row := findVM(rep, "vm1") 670 row := findVM(rep, "vm1")
598 require.NotNil(t, row) 671 require.NotNil(t, row)
599 assert.Equal(t, "creating", row.Phase, "attempt %d stays in the retry budget", i) 672 assert.Equal(t, "creating", row.Phase, "attempt %d stays in the retry budget", i)
600 } 673 }
601 rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) 674 rep := f.step(snap(1, vm("vm1")))
602 row := findVM(rep, "vm1") 675 row := findVM(rep, "vm1")
603 require.NotNil(t, row) 676 require.NotNil(t, row)
604 assert.Equal(t, "failed", row.Phase, "budget spent -> terminal failed") 677 assert.Equal(t, "failed", row.Phase, "budget spent -> terminal failed")
internal/agent/reconcile/worker.go
Old New
@@ -0,0 +1,248 @@
1 package reconcile
2
3 import (
4 "context"
5 "sync"
6
7 "github.com/a73x/eitri/internal/pb"
8 )
9
10 // manager owns one goroutine per VM — the demux half of the reconcile loop.
11 // Step hands it one assignment per VM, it routes each to that VM's worker, and
12 // it collects every worker's last-published result for the host report.
13 //
14 // Concurrency contract:
15 // - one goroutine per VM, so every operation on a single VM is serialized for
16 // free: the worker IS the lock, and there is no per-VM mutex;
17 // - a worker holds its own lock only to swap an assignment in or a result
18 // out, never across a reconcile pass, so deliver and collect never wait on
19 // slow work — that is what protects the heartbeat;
20 // - anything shared BETWEEN VMs is guarded where it lives: the admission
21 // ledger under Engine.mu (see admit), the DHCP reservation table under the
22 // dhcp server's own lock, and the state store by one file per VM written
23 // via temp-file rename.
24 type manager struct {
25 eng *Engine
26
27 mu sync.Mutex
28 workers map[string]*worker
29 // tombstoned is the delete set from the most recent accepted snapshot. The
30 // destroy ack is level-triggered from it, so aggregate needs it even when
31 // it is called without a fresh snapshot.
32 tombstoned map[string]bool
33 stopped bool
34 }
35
36 func newManager(eng *Engine) *manager {
37 return &manager{eng: eng, workers: map[string]*worker{}, tombstoned: map[string]bool{}}
38 }
39
40 // manager returns the engine's worker manager, constructing it on first use.
41 // Lazy construction rather than a Start method keeps Step the single entry
42 // point, so syncclient and cmd/eitri-agent need no extra wiring.
43 func (e *Engine) manager() *manager {
44 e.mgrOnce.Do(func() { e.mgr = newManager(e) })
45 return e.mgr
46 }
47
48 // deliver hands a VM its latest assignment, spawning its worker on first sight.
49 // It never blocks on the worker: an assignment arriving while the worker is
50 // mid-pass simply replaces any unconsumed one (coalescing — latest desired
51 // wins) and is picked up when the current pass ends.
52 func (m *manager) deliver(id string, a assignment) {
53 m.mu.Lock()
54 if m.stopped {
55 m.mu.Unlock()
56 return
57 }
58 w, ok := m.workers[id]
59 if !ok {
60 w = newWorker(m.eng, id)
61 m.workers[id] = w
62 go w.run()
63 }
64 m.mu.Unlock()
65
66 w.mu.Lock()
67 w.pending = &a
68 w.cond.Broadcast()
69 w.mu.Unlock()
70 }
71
72 // reapAbsent stops the workers for VMs present in neither desired state nor
73 // local records — nothing is left to reconcile. The result of a reaped worker
74 // is dropped with it, which is correct: a destroyed VM contributes no report row.
75 //
76 // Only IDLE workers are reaped. A worker still holding an in-flight (or pending)
77 // pass owns that VM, and dropping it from the map would let the next deliver for
78 // the same id spawn a SECOND worker — two goroutines reconciling one VM at once,
79 // which is exactly the invariant this layer exists to provide. A concurrent
80 // Kill/DeleteTap/DeleteVM against a Boot/CreateTap/SaveVM can orphan a
81 // cloud-hypervisor process with no record left to find it by, and nothing
82 // self-heals from that. Reaping is level-triggered, so deferring a busy worker
83 // to a later tick costs nothing. This is reachable in ordinary operation: a
84 // transiently unreadable record makes LoadVMs skip a live VM (it continues past
85 // read errors), dropping it out of live for one tick.
86 func (m *manager) reapAbsent(live map[string]assignment) {
87 m.mu.Lock()
88 defer m.mu.Unlock()
89 for id, w := range m.workers {
90 if _, ok := live[id]; ok {
91 continue
92 }
93 w.mu.Lock()
94 idle := w.pending == nil && !w.busy
95 w.mu.Unlock()
96 if !idle {
97 continue // still owns this VM; reap it on a later tick
98 }
99 delete(m.workers, id)
100 w.stop()
101 }
102 }
103
104 func (m *manager) setTombstoned(ids map[string]bool) {
105 m.mu.Lock()
106 defer m.mu.Unlock()
107 m.tombstoned = ids
108 }
109
110 // tombstones returns the current delete set. The map is returned rather than
111 // copied: setTombstoned replaces it wholesale on every dispatch and nothing
112 // ever mutates one in place, so a reader can hold an older map safely.
113 func (m *manager) tombstones() map[string]bool {
114 m.mu.Lock()
115 defer m.mu.Unlock()
116 return m.tombstoned
117 }
118
119 // collect folds every worker's last-published result into rep. Each read takes
120 // only the worker's lock, which is never held across a pass, so a VM wedged in
121 // a multi-second operation cannot delay the report — it simply contributes its
122 // previous result, or nothing if it has not published yet.
123 //
124 // The rows are CLONED, so every report owns its own protos. A worker that
125 // publishes once and then stays busy is otherwise aliased by every report taken
126 // between two publishes, and proto.Marshal writes a size cache into the message
127 // it marshals — two reports marshalled concurrently would race on one message.
128 // Cloning makes the report self-contained instead of resting on an assumption
129 // about how many goroutines might serialize it.
130 func (m *manager) collect(rep *pb.ActualStateReport) {
131 for _, w := range m.snapshot() {
132 w.mu.Lock()
133 res := w.result
134 w.mu.Unlock()
135 // Cloned outside the lock: a published result is never mutated in
136 // place, so its protos are safe to read once the pointer is in hand.
137 own := res.clone()
138 own.merge(rep)
139 }
140 }
141
142 // snapshot returns the current worker set, so callers can walk it without
143 // holding the manager lock while touching workers.
144 func (m *manager) snapshot() []*worker {
145 m.mu.Lock()
146 defer m.mu.Unlock()
147 out := make([]*worker, 0, len(m.workers))
148 for _, w := range m.workers {
149 out = append(out, w)
150 }
151 return out
152 }
153
154 // Stop shuts down every per-VM worker. It signals and returns without waiting: a
155 // worker mid-pass exits when that pass ends (bounded by VMTimeout).
156 //
157 // TERMINAL: an Engine cannot be restarted. After Stop, Step still advances the
158 // persisted epoch and returns a report, but dispatches nothing and reports EMPTY
159 // actual state — which the control plane reads as every VM on this host having
160 // vanished. Production never calls it (cmd/eitri-agent lets the process exit and
161 // the OS reclaim the goroutines); it exists so a test's cleanup can tear workers
162 // down between cases without leaking goroutines. Because that sole caller is a
163 // cross-package test (internal/integration), it cannot live in a _test.go, so
164 // the deadcode gate allowlists it.
165 func (e *Engine) Stop() {
166 m := e.manager()
167 m.mu.Lock()
168 defer m.mu.Unlock()
169 m.stopped = true
170 for id, w := range m.workers {
171 delete(m.workers, id)
172 w.stop()
173 }
174 }
175
176 // worker owns exactly one VM's reconcile. Its goroutine is the serialization
177 // primitive: at most one operation on that VM is ever in flight.
178 type worker struct {
179 eng *Engine
180 id string
181
182 mu sync.Mutex
183 cond *sync.Cond
184 // pending is the latest assignment waiting to be reconciled. A newer one
185 // overwrites an unconsumed one — a burst of desired-state updates collapses
186 // into a single pass against the newest.
187 pending *assignment
188 // busy reports that a pass is running (with mu released).
189 busy bool
190 // stopped ends the goroutine after the current pass.
191 stopped bool
192 // result is this VM's last-published contribution to the host report.
193 result vmResult
194 }
195
196 func newWorker(eng *Engine, id string) *worker {
197 w := &worker{eng: eng, id: id}
198 w.cond = sync.NewCond(&w.mu)
199 return w
200 }
201
202 // run is the worker loop: take the latest assignment, reconcile, publish.
203 //
204 // Each pass gets a fresh context.Background rather than the context of the Step
205 // that dispatched it: a pass outlives its Step by design, so inheriting that
206 // context would cancel the work the instant the report went out. VMTimeout is
207 // what bounds a pass (see reconcileOne).
208 func (w *worker) run() {
209 for {
210 w.mu.Lock()
211 for w.pending == nil && !w.stopped {
212 w.cond.Wait()
213 }
214 if w.stopped {
215 w.mu.Unlock()
216 return
217 }
218 a := *w.pending
219 w.pending = nil
220 w.busy = true
221 w.mu.Unlock()
222
223 // LOAD-BEARING: the pass runs with w.mu RELEASED. That is what lets
224 // deliver and collect run at full speed while this VM does slow work.
225 res, ok := w.eng.reconcileOne(context.Background(), w.id, a)
226
227 w.mu.Lock()
228 // Publish only a pass that ran. A skipped pass (this VM's record could
229 // not be read) has observed nothing, and publishing its empty result
230 // would blank the VM out of every host report until the next pass
231 // succeeds — the control plane would see the VM as gone from this host.
232 // Keeping the previous result reports the VM's last known state instead,
233 // which is what a level-triggered loop should do while it retries.
234 if ok {
235 w.result = res
236 }
237 w.busy = false
238 w.cond.Broadcast()
239 w.mu.Unlock()
240 }
241 }
242
243 func (w *worker) stop() {
244 w.mu.Lock()
245 w.stopped = true
246 w.cond.Broadcast()
247 w.mu.Unlock()
248 }
internal/agent/reconcile/worker_test.go
Old New
@@ -0,0 +1,219 @@
1 package reconcile
2
3 import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/a73x/eitri/internal/pb"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 )
12
13 // wedge returns an Images func that blocks every fetch until release is closed,
14 // and signals arrived once per call so a test can observe how many VMs are
15 // inside a slow operation at the same moment.
16 func wedge(arrived chan<- struct{}, release <-chan struct{}) func(context.Context, string, string) (string, error) {
17 return func(ctx context.Context, url, sha string) (string, error) {
18 select {
19 case arrived <- struct{}{}:
20 default:
21 }
22 select {
23 case <-release:
24 return "/cache/x.raw", nil
25 case <-ctx.Done():
26 return "", ctx.Err()
27 }
28 }
29 }
30
31 // stepNoWait drives one tick the way production does: dispatch and take the
32 // report without waiting on any worker. It is the f.step helper's opposite —
33 // f.step waits for every worker to go idle, which is exactly the blocking these
34 // tests exist to prove production does NOT do — and it fails the test if Step
35 // does not come back promptly. Every test below wedges a VM, so a Step that
36 // waited on its workers would hang the whole package instead of naming the
37 // property that broke.
38 func stepNoWait(t *testing.T, f *fixture, s *pb.DesiredStateSnapshot) *pb.ActualStateReport {
39 t.Helper()
40 done := make(chan *pb.ActualStateReport, 1)
41 go func() { done <- f.eng.Step(context.Background(), s) }()
42 select {
43 case rep := <-done:
44 return rep
45 case <-time.After(2 * time.Second):
46 t.Fatal("Step blocked on a busy VM: the heartbeat is not protected")
47 return nil
48 }
49 }
50
51 // TestBusyVMDoesNotBlockTheReport is the headline property: a VM wedged in a
52 // slow operation must not delay the host report. This is why the reconcile work
53 // lives in per-VM workers at all — the heartbeat is what keeps the host online.
54 func TestBusyVMDoesNotBlockTheReport(t *testing.T) {
55 f := setup(t)
56 arrived := make(chan struct{}, 1)
57 release := make(chan struct{})
58 f.eng.Images = wedge(arrived, release)
59
60 // Dispatch vm1 and wait until its create is actually inside the slow fetch.
61 stepNoWait(t, f, snap(1, vm("vm1")))
62 select {
63 case <-arrived:
64 case <-time.After(2 * time.Second):
65 t.Fatal("worker never started the create")
66 }
67
68 // The next tick must come back promptly even though vm1 is still wedged.
69 rep := stepNoWait(t, f, snap(1, vm("vm1")))
70 assert.Nil(t, findVM(rep, "vm1"), "a VM that has never published contributes no row")
71
72 // Once the operation completes the VM publishes and appears in the report.
73 close(release)
74 f.eng.manager().waitIdle()
75 assert.Equal(t, "ready", findVM(f.aggregateNow(1), "vm1").GetPhase())
76 }
77
78 // TestVMsReconcileConcurrently pins that one VM's slow operation does not hold
79 // up another's: under the old single-threaded step, vm2 could not start until
80 // vm1 finished.
81 func TestVMsReconcileConcurrently(t *testing.T) {
82 f := setup(t)
83 arrived := make(chan struct{}, 2)
84 release := make(chan struct{})
85 f.eng.Images = wedge(arrived, release)
86
87 stepNoWait(t, f, snap(1, vm("vm1"), vm("vm2")))
88
89 for i := 0; i < 2; i++ {
90 select {
91 case <-arrived:
92 case <-time.After(2 * time.Second):
93 t.Fatal("VMs did not reconcile concurrently: one worker blocked the other")
94 }
95 }
96 close(release)
97 f.eng.manager().waitIdle()
98 }
99
100 // TestLatestDesiredWinsForABusyVM pins coalescing: desired-state updates that
101 // arrive while a VM is busy overwrite each other, so the worker reconciles
102 // against the NEWEST desired when it comes free — a superseded intermediate
103 // state is never acted on.
104 func TestLatestDesiredWinsForABusyVM(t *testing.T) {
105 f := setup(t)
106 arrived := make(chan struct{}, 1)
107 release := make(chan struct{})
108 f.eng.Images = wedge(arrived, release)
109
110 stepNoWait(t, f, snap(1, vm("vm1")))
111 select {
112 case <-arrived:
113 case <-time.After(2 * time.Second):
114 t.Fatal("worker never started the create")
115 }
116
117 // Both land while vm1 is wedged; the second must overwrite the first.
118 stepNoWait(t, f, snap(2, vm("vm1", stopped)))
119 stepNoWait(t, f, snap(3, vm("vm1")))
120
121 close(release)
122 f.eng.manager().waitIdle()
123
124 rep := f.aggregateNow(3) // the newest snapshot the worker reconciled against
125 assert.Equal(t, "running", findVM(rep, "vm1").GetPower())
126 assert.Empty(t, f.prov.shutdown, "the superseded 'stopped' desired must never be reconciled")
127 }
128
129 // TestWorkerLifecycleTracksLiveVMs pins spawn-on-first-sight and reap-when-gone:
130 // worker count tracks the union of desired state and local records, so a
131 // destroyed VM leaves no goroutine behind.
132 func TestWorkerLifecycleTracksLiveVMs(t *testing.T) {
133 f := setup(t)
134 m := f.eng.manager()
135
136 f.step(snap(1, vm("vm1")))
137 require.Equal(t, 1, m.count(), "a worker is spawned on first sight")
138
139 f.step(snap(2, tombstoned(vm("vm1"))))
140 assert.Equal(t, 1, m.count(), "quarantined: still has a record, still reconciling")
141
142 f.now = f.now.Add(6 * time.Minute) // past TombstoneGrace
143 f.step(snap(2, tombstoned(vm("vm1"))))
144 recs, _ := f.st.LoadVMs()
145 require.NotContains(t, recs, "vm1", "precondition: destroyed")
146 assert.Equal(t, 1, m.count(), "still in desired (tombstoned), awaiting the destroy ack")
147
148 f.step(snap(3))
149 assert.Equal(t, 0, m.count(), "gone from desired and records: worker reaped")
150 }
151
152 // TestStopEndsEveryWorker pins shutdown: Stop drops every worker so none
153 // outlives the engine.
154 func TestStopEndsEveryWorker(t *testing.T) {
155 f := setup(t)
156 f.step(snap(1, vm("vm1"), vm("vm2")))
157 require.Equal(t, 2, f.eng.manager().count())
158
159 f.eng.Stop()
160 assert.Equal(t, 0, f.eng.manager().count())
161 }
162
163 // TestCreateConcurrencyIsCapped pins the create throttle: per-VM workers made
164 // creates concurrent, and without a cap N simultaneous VMs mean N image fetches
165 // and N multi-GB disk copies against one device. Three VMs are dispatched under
166 // a cap of 2, so the third must wait for a slot.
167 func TestCreateConcurrencyIsCapped(t *testing.T) {
168 f := setup(t)
169 f.eng.MaxConcurrentCreates = 2
170
171 entered := make(chan struct{}, 3)
172 release := make(chan struct{})
173 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
174 entered <- struct{}{}
175 <-release
176 return "/cache/x.raw", nil
177 }
178
179 f.eng.Step(context.Background(), snap(1, vm("vm1"), vm("vm2"), vm("vm3")))
180
181 // Two VMs take the available slots.
182 for i := 0; i < 2; i++ {
183 select {
184 case <-entered:
185 case <-time.After(2 * time.Second):
186 t.Fatal("the throttle should admit up to its cap")
187 }
188 }
189
190 // The third must NOT enter while the first two still hold their slots.
191 select {
192 case <-entered:
193 t.Fatal("a third VM entered the I/O region: the create cap is not enforced")
194 case <-time.After(250 * time.Millisecond):
195 }
196
197 close(release)
198 f.eng.manager().waitIdle()
199 assert.Len(t, f.prov.prepared, 3, "all three VMs still complete, just not at once")
200 }
201
202 // TestCreateConcurrencyZeroIsUnlimited: the zero value must not throttle.
203 func TestCreateConcurrencyZeroIsUnlimited(t *testing.T) {
204 f := setup(t)
205 arrived := make(chan struct{}, 3)
206 release := make(chan struct{})
207 f.eng.Images = wedge(arrived, release)
208
209 f.eng.Step(context.Background(), snap(1, vm("vm1"), vm("vm2"), vm("vm3")))
210 for i := 0; i < 3; i++ {
211 select {
212 case <-arrived:
213 case <-time.After(2 * time.Second):
214 t.Fatal("MaxConcurrentCreates==0 must not throttle")
215 }
216 }
217 close(release)
218 f.eng.manager().waitIdle()
219 }
internal/agent/state/state.go
Old New
@@ -159,19 +159,33 @@ func (s *Store) LoadVMs() (map[string]Record, error) {
159 return out, nil 159 return out, nil
160 } 160 }
161 161
162 // Get loads a single VM's record by id, returning ok=false when this host has 162 // Get loads a single VM's record by id. ok reports that a record EXISTS; err is
163 // no such record (unknown VM / not running here). Like LoadVMs, an unreadable 163 // non-nil when a record exists but could not be read or parsed. Absence
164 // or unparseable record reads as absent. 164 // (ok=false, err=nil) is reported ONLY for a record that is genuinely not there.
165 func (s *Store) Get(vmID string) (Record, bool) { 165 //
166 // Callers MUST distinguish the two. "No record" means this host does not run
167 // this VM, so the agent's reconcile loop answers it by creating the VM — which
168 // rebuilds disk.raw and boots cloud-hypervisor. Letting an unreadable record
169 // read as absent would put a LIVE VM down that path: PrepareDisk over the disk
170 // its guest is running from, and a second hypervisor whose pidfile orphans the
171 // first. Anything that cannot be observed must be retried, never assumed empty.
172 //
173 // LoadVMs skips unreadable records instead, because a scan that cannot parse a
174 // file cannot say which VM it belonged to. A single-record read knows exactly
175 // which VM it failed to observe, so it says so.
176 func (s *Store) Get(vmID string) (Record, bool, error) {
166 raw, err := os.ReadFile(filepath.Join(s.VMDir(vmID), "record.json")) 177 raw, err := os.ReadFile(filepath.Join(s.VMDir(vmID), "record.json"))
167 if err != nil { 178 if err != nil {
168 return Record{}, false 179 if os.IsNotExist(err) {
180 return Record{}, false, nil // no such record on this host
181 }
182 return Record{}, false, err
169 } 183 }
170 var rec Record 184 var rec Record
171 if err := json.Unmarshal(raw, &rec); err != nil { 185 if err := json.Unmarshal(raw, &rec); err != nil {
172 return Record{}, false 186 return Record{}, false, err
173 } 187 }
174 return rec, true 188 return rec, true, nil
175 } 189 }
176 190
177 // DeleteVM removes the entire VM directory (record + disk + seed + socket). 191 // DeleteVM removes the entire VM directory (record + disk + seed + socket).
internal/agent/state/state_test.go
Old New
@@ -56,6 +56,28 @@ func TestDeleteVMRemovesRecordAndDir(t *testing.T) {
56 assert.True(t, os.IsNotExist(err)) 56 assert.True(t, os.IsNotExist(err))
57 } 57 }
58 58
59 // TestUnreadableRecordDoesNotReadAsAbsent pins the distinction Get exists to
60 // make. A record that is there but unparseable reports an error; only a record
61 // that is genuinely not there reports plain absence. The agent's reconcile loop
62 // answers "no record" by creating the VM, so collapsing the two would rebuild a
63 // live VM's disk under its running guest.
64 func TestUnreadableRecordDoesNotReadAsAbsent(t *testing.T) {
65 s := open(t)
66 require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}, BootID: "boot-1"}))
67 recPath := filepath.Join(s.VMDir("vm1"), "record.json")
68 require.NoError(t, os.WriteFile(recPath, []byte("{not json"), 0o600))
69
70 rec, ok, err := s.Get("vm1")
71 assert.False(t, ok, "an unparseable record yields no usable record")
72 assert.Error(t, err, "...but it must not read as absent")
73 assert.Equal(t, Record{}, rec)
74
75 rec, ok, err = s.Get("no-such-vm")
76 assert.False(t, ok)
77 assert.NoError(t, err, "a record that was never written is plain absence")
78 assert.Equal(t, Record{}, rec)
79 }
80
59 func TestMACIsDeterministicAndLocallyAdministered(t *testing.T) { 81 func TestMACIsDeterministicAndLocallyAdministered(t *testing.T) {
60 got := MAC("vm-abc123") 82 got := MAC("vm-abc123")
61 if got != MAC("vm-abc123") { 83 if got != MAC("vm-abc123") {
internal/agent/syncclient/client.go
Old New
@@ -467,7 +467,13 @@ func (c *Client) handleTCPStream(ctx context.Context, stream io.ReadWriteCloser,
467 _ = transport.WriteMsg(stream, &pb.AgentMessage{Msg: &pb.AgentMessage_TcpOpened{ 467 _ = transport.WriteMsg(stream, &pb.AgentMessage{Msg: &pb.AgentMessage_TcpOpened{
468 TcpOpened: &pb.TCPOpened{Ok: false, Error: msg}}}) 468 TcpOpened: &pb.TCPOpened{Ok: false, Error: msg}}})
469 } 469 }
470 rec, ok := c.St.Get(vmID) 470 rec, ok, err := c.St.Get(vmID)
471 if err != nil {
472 // The record is there but unreadable, so whether this host runs the VM
473 // cannot be answered — refuse without claiming it is absent.
474 refuse("vm record unreadable on this host")
475 return
476 }
471 if !ok { 477 if !ok {
472 refuse("vm not on this host") 478 refuse("vm not on this host")
473 return 479 return