fd31fa9e
feat(agent): reconcile each VM in its own worker
a73x 2026-07-26 08:24
Commit message
.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 |