a73x

b4794065

feat(agent): guest images are decoded in-process, not by a tool

a73x   2026-08-06 09:12

Commit message
feat(agent): guest images are decoded in-process, not by a tool

qcow2, vmdk, vhdx, vdi, parallels, vpc and asif are parsed in the agent by
go-qcow2reader; anything none of them claims is raw. The `qemu-img` dependency
goes with it — a host needs KVM and nothing else, and the operator instruction
to install an image toolchain goes away.

That is what lets a host with no such toolchain exist at all, which is the
prerequisite for a platform where one cannot be installed. An unparsable header
is a permanent error naming the artifact, not a guest that quietly fails to boot.

README.md
Old New
@@ -36,7 +36,7 @@ The agent owns everything host-local: resource admission (vCPU / memory / disk /
36 address are admitted through one serialized gate), IP allocation (an embedded 36 address are admitted through one serialized gate), IP allocation (an embedded
37 DHCP server hands each VM a sticky, deterministic address and reserves it at 37 DHCP server hands each VM a sticky, deterministic address and reserves it at
38 create), and a content-addressed image cache (each base image is downloaded and 38 create), and a content-addressed image cache (each base image is downloaded and
39 `qemu-img`-converted once, then reflink-copied per guest). 39 decoded to raw once, then reflink-copied per guest).
40 40
41 ## Components 41 ## Components
42 42
docs/assumptions.md
Old New
@@ -143,3 +143,24 @@ Underpins choosing gzipped raw over plain raw.
143 **Partly proven**: measured on one host (6 vCPU, nested). A slow host pays more 143 **Partly proven**: measured on one host (6 vCPU, nested). A slow host pays more
144 CPU for less transfer, which is the right trade on a LAN and the wrong one only 144 CPU for less transfer, which is the right trade on a LAN and the wrong one only
145 if a host is very slow and very well connected. 145 if a host is very slow and very well connected.
146
147 ### Guest images are ours to decode, not a tool's
148
149 qcow2, vmdk, vhdx, vdi, parallels, vpc and asif are parsed in-process by
150 go-qcow2reader; anything none of them claims is raw. Underpins dropping the
151 `qemu-img` dependency outright rather than making it a per-host capability, and
152 so underpins a macOS host booting a stock cloud image at all.
153 **Proven** for qcow2: a real qcow2 fixture decodes byte-identically, trailing
154 zeros and offsets included. **Unverified** for the other six formats — we
155 publish none of them and the library's tests are the only evidence they work.
156 The failure is legible either way: an unparsable header is a permanent error
157 naming the artifact, not a guest that quietly refuses to boot.
158
159 ### A gzipped image is raw inside
160
161 Gzip is a stream, so the decoder — which needs random access — cannot look
162 inside one. A `.gz` is therefore decompressed and its first bytes sniffed
163 directly, and a gzipped qcow2 is refused rather than decoded.
164 **Proven** for the qcow2 case. A gzipped vmdk would slip through as raw and
165 fail at boot; nobody publishes one, and the fix if they do is to decompress to
166 a temp file and hand it to the decoder.
docs/quickstart.md
Old New
@@ -88,8 +88,9 @@ your laptop. `192.0.2.10` is the server below. Substitute yours.
88 88
89 ### What you need 89 ### What you need
90 90
91 Every VM host needs KVM (`ls -l /dev/kvm`) and `qemu-img` (Debian/Ubuntu: 91 Every VM host needs KVM (`ls -l /dev/kvm`). Guest images are decoded in the
92 `qemu-utils`, Fedora: `qemu-img`). The agent fetches cloud-hypervisor and the 92 agent, so there is no image toolchain to install. The agent fetches
93 cloud-hypervisor and the
93 guest firmware itself on first start, sha-verified against the release. To 94 guest firmware itself on first start, sha-verified against the release. To
94 manage them by hand instead, disable it in `/etc/default/eitri-agent`: 95 manage them by hand instead, disable it in `/etc/default/eitri-agent`:
95 `EITRI_AGENT_FLAGS="--bootstrap-url="`. 96 `EITRI_AGENT_FLAGS="--bootstrap-url="`.
docs/shape.html
Old New
@@ -180,10 +180,8 @@
180 { 180 {
181 "importPath": "internal/agent/imagecache", 181 "importPath": "internal/agent/imagecache",
182 "plane": "data", 182 "plane": "data",
183 "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).", 183 "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).",
184 "imports": [ 184 "imports": []
185 "internal/agent/exec"
186 ]
187 }, 185 },
188 { 186 {
189 "importPath": "internal/agent/ipalloc", 187 "importPath": "internal/agent/ipalloc",
docs/shape.json
Old New
@@ -129,10 +129,8 @@
129 { 129 {
130 "importPath": "internal/agent/imagecache", 130 "importPath": "internal/agent/imagecache",
131 "plane": "data", 131 "plane": "data",
132 "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).", 132 "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).",
133 "imports": [ 133 "imports": []
134 "internal/agent/exec"
135 ]
136 }, 134 },
137 { 135 {
138 "importPath": "internal/agent/ipalloc", 136 "importPath": "internal/agent/ipalloc",
go.mod
Old New
@@ -7,6 +7,7 @@ require (
7 github.com/coreos/go-oidc/v3 v3.20.0 7 github.com/coreos/go-oidc/v3 v3.20.0
8 github.com/diskfs/go-diskfs v1.9.3 8 github.com/diskfs/go-diskfs v1.9.3
9 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82 9 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
10 github.com/lima-vm/go-qcow2reader v0.7.1
10 github.com/modelcontextprotocol/go-sdk v1.6.1 11 github.com/modelcontextprotocol/go-sdk v1.6.1
11 github.com/pkg/sftp v1.13.11 12 github.com/pkg/sftp v1.13.11
12 github.com/quic-go/quic-go v0.48.2 13 github.com/quic-go/quic-go v0.48.2
go.sum
Old New
@@ -46,6 +46,8 @@ github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBF
46 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 46 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
47 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 47 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
48 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 48 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
49 github.com/lima-vm/go-qcow2reader v0.7.1 h1:fZ5u38uaRX3ukuVA6IpeImh9BfRhzRGvTr87yGqENbY=
50 github.com/lima-vm/go-qcow2reader v0.7.1/go.mod h1:Ai9fcmE2dXF6YFomrSCttveOT1x3+x5eG7AfIUUnSqw=
49 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 51 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
50 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 52 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
51 github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= 53 github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
internal/agent/imagecache/imagecache.go
Old New
@@ -1,5 +1,5 @@
1 // Package imagecache downloads and verifies content-addressed base images 1 // Package imagecache downloads and verifies content-addressed base images
2 // (raw-converted via qemu-img, LRU-evicted beyond MaxBytes). Layout: 2 // (decoded to raw in-process, 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). 5 // beyond MaxBytes are evicted (never the one just ensured).
@@ -31,15 +31,16 @@ import (
31 "sort" 31 "sort"
32 "time" 32 "time"
33 33
34 "github.com/lima-vm/go-qcow2reader"
35 "github.com/lima-vm/go-qcow2reader/convert"
36 "github.com/lima-vm/go-qcow2reader/image"
37 "github.com/lima-vm/go-qcow2reader/image/raw"
34 "golang.org/x/sync/singleflight" 38 "golang.org/x/sync/singleflight"
35
36 "github.com/a73x/eitri/internal/agent/exec"
37 ) 39 )
38 40
39 // httpDoer is the slice of *http.Client the cache needs to fetch images. It is 41 // httpDoer is the slice of *http.Client the cache needs to fetch images. It is
40 // a field on Cache, not a package global, so a test can stub the transport the 42 // a field on Cache, not a package global, so a test can stub the transport —
41 // same way it stubs command execution through run — exercising slow, hung, and 43 // exercising slow, hung, and error responses without reaching the network.
42 // error responses without reaching the network.
43 type httpDoer interface { 44 type httpDoer interface {
44 Do(*http.Request) (*http.Response, error) 45 Do(*http.Request) (*http.Response, error)
45 } 46 }
@@ -50,7 +51,6 @@ var sha256Re = regexp.MustCompile(`^[a-f0-9]{64}$`)
50 51
51 type Cache struct { 52 type Cache struct {
52 dir string 53 dir string
53 run exec.Runner
54 http httpDoer 54 http httpDoer
55 55
56 // MaxBytes caps the summed size of cached images; 0 disables eviction. 56 // MaxBytes caps the summed size of cached images; 0 disables eviction.
@@ -62,18 +62,18 @@ type Cache struct {
62 MaxBytes int64 62 MaxBytes int64
63 63
64 // fetching collapses concurrent Ensure calls for the same image into one 64 // fetching collapses concurrent Ensure calls for the same image into one
65 // download+convert. Per-VM reconcile workers made creates concurrent, so a 65 // download+decode. Per-VM reconcile workers made creates concurrent, so a
66 // fleet rolling out one image now fetches it from every VM's worker at once; 66 // fleet rolling out one image now fetches it from every VM's worker at once;
67 // without this that is N identical multi-GB downloads and N qemu-img 67 // without this that is N identical multi-GB downloads and N decodes, all
68 // converts, all racing to rename onto the same final path. 68 // racing to rename onto the same final path.
69 fetching singleflight.Group 69 fetching singleflight.Group
70 } 70 }
71 71
72 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout 72 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout
73 // so large images on slow links still complete, but a hung connection cannot 73 // so large images on slow links still complete, but a hung connection cannot
74 // stall a reconcile worker forever. 74 // stall a reconcile worker forever.
75 func New(dir string, run exec.Runner) *Cache { 75 func New(dir string) *Cache {
76 return &Cache{dir: dir, run: run, http: &http.Client{Timeout: 10 * time.Minute}} 76 return &Cache{dir: dir, http: &http.Client{Timeout: 10 * time.Minute}}
77 } 77 }
78 78
79 // fetch downloads url into a temp file in the cache dir and verifies its 79 // fetch downloads url into a temp file in the cache dir and verifies its
@@ -113,7 +113,7 @@ func (c *Cache) fetch(ctx context.Context, url, sha string) (string, error) {
113 } 113 }
114 114
115 // Ensure returns the local path to the raw base image for sha, fetching and 115 // Ensure returns the local path to the raw base image for sha, fetching and
116 // converting it if the cache does not already hold it. 116 // decoding it if the cache does not already hold it.
117 // 117 //
118 // Concurrent calls for the SAME sha are collapsed into one fetch: the first 118 // Concurrent calls for the SAME sha are collapsed into one fetch: the first
119 // caller does the work and the rest wait for its result. Images are content- 119 // caller does the work and the rest wait for its result. Images are content-
@@ -161,22 +161,35 @@ func (c *Cache) ensureOnce(ctx context.Context, url, sha string) (string, error)
161 // A no-op once the raw path has renamed tmp away; every other path needs it. 161 // A no-op once the raw path has renamed tmp away; every other path needs it.
162 defer os.Remove(tmp) 162 defer os.Remove(tmp)
163 163
164 // Two authorities, and which answers is decided by what the question is.
165 // sniffFile answers a TRANSPORT question — is this gzipped? — because gzip
166 // wraps a disk image rather than being one. Everything else is a disk format,
167 // and go-qcow2reader is the authority there: it parses qcow2, vmdk, vhdx,
168 // vdi, parallels, vpc and asif headers, and falls back to raw for what none
169 // of them claim. That fallback is the same default-to-raw rule this package
170 // started with, now with six fewer formats misfiling under it.
164 format, err := sniffFile(tmp) 171 format, err := sniffFile(tmp)
165 if err != nil { 172 if err != nil {
166 return "", fmt.Errorf("imagecache: identify %s: %w", url, err) 173 return "", fmt.Errorf("imagecache: identify %s: %w", url, err)
167 } 174 }
168 if format == formatRaw { 175 if format != formatGzip {
169 // The verified download IS the base image. Renaming it onto final is 176 passthrough, err := isRawImage(url, tmp)
170 // atomic (same directory) and costs neither a tool nor a second copy of a 177 if err != nil {
171 // multi-gigabyte file. This is the path a host without qemu-img has. 178 return "", err
172 if err := os.Chmod(tmp, 0o644); err != nil {
173 return "", fmt.Errorf("imagecache: chmod image: %w", err)
174 } 179 }
175 if err := os.Rename(tmp, final); err != nil { 180 if passthrough {
176 return "", fmt.Errorf("imagecache: rename to final: %w", err) 181 // The verified download IS the base image. Renaming it onto final is
182 // atomic (same directory) and costs neither a decode pass nor a second
183 // copy of a multi-gigabyte file.
184 if err := os.Chmod(tmp, 0o644); err != nil {
185 return "", fmt.Errorf("imagecache: chmod image: %w", err)
186 }
187 if err := os.Rename(tmp, final); err != nil {
188 return "", fmt.Errorf("imagecache: rename to final: %w", err)
189 }
190 c.evict(final)
191 return final, nil
177 } 192 }
178 c.evict(final)
179 return final, nil
180 } 193 }
181 194
182 // Materialize into a UNIQUE temp file first, then rename onto final atomically 195 // Materialize into a UNIQUE temp file first, then rename onto final atomically
@@ -194,13 +207,12 @@ func (c *Cache) ensureOnce(ctx context.Context, url, sha string) (string, error)
194 _ = os.Remove(converting) // reserve the unique name; let the writer create it fresh 207 _ = os.Remove(converting) // reserve the unique name; let the writer create it fresh
195 defer os.Remove(converting) 208 defer os.Remove(converting)
196 209
197 // sniff returns exactly three formats and raw returned above, so these two 210 // Gzip returned raw above only if it was not gzip, so this is the whole
198 // cases are the whole space; a fourth format means editing both. 211 // space: unwrap the transport, or decode the container.
199 switch format { 212 if format == formatGzip {
200 case formatGzip:
201 err = decompress(url, tmp, converting) 213 err = decompress(url, tmp, converting)
202 case formatQcow2: 214 } else {
203 err = c.convert(ctx, url, tmp, converting) 215 err = decode(url, tmp, converting)
204 } 216 }
205 if err != nil { 217 if err != nil {
206 return "", err 218 return "", err
@@ -245,7 +257,7 @@ func decompress(url, src, dst string) error {
245 if sniff(head) == formatQcow2 { 257 if sniff(head) == formatQcow2 {
246 // Permanent for the same reason a broken header is: the sha matched, so 258 // Permanent for the same reason a broken header is: the sha matched, so
247 // these bytes are what the publisher published. 259 // these bytes are what the publisher published.
248 return permanentf("imagecache: %s decompresses to a qcow2 image; eitri guest images must be raw or gzipped raw (.raw.gz) — convert it with `qemu-img convert -O raw` where you publish it", url) 260 return permanentf("imagecache: %s decompresses to a qcow2 image; gzip must wrap a RAW image — publish the qcow2 ungzipped (eitri decodes it) or gzip the raw", url)
249 } 261 }
250 262
251 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) 263 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
@@ -262,16 +274,90 @@ func decompress(url, src, dst string) error {
262 return nil 274 return nil
263 } 275 }
264 276
265 // convert turns a qcow2 download into a raw image with qemu-img — the one 277 // isRawImage reports whether the download at path is already a raw image and
266 // format that still needs a tool, and the one a host without it cannot boot. 278 // can be used as-is. Anything go-qcow2reader recognizes as a container — qcow2,
267 func (c *Cache) convert(ctx context.Context, url, src, dst string) error { 279 // vmdk, vhdx, vdi, parallels, vpc, asif — needs decoding first.
268 if _, err := c.run(ctx, "qemu-img", "convert", "-O", "raw", src, dst); err != nil { 280 func isRawImage(url, path string) (bool, error) {
269 return fmt.Errorf("qemu-img convert %s: %w", url, err) 281 img, err := openImage(url, path)
282 if err != nil {
283 return false, err
284 }
285 defer img.Close()
286 return img.Type() == raw.Type, nil
287 }
288
289 // openImage parses the download's header. A header this cannot parse is a
290 // property of the artifact, not of the attempt: the checksum already matched,
291 // so a re-fetch returns the same bytes and fails identically.
292 func openImage(url, path string) (*imageFile, error) {
293 f, err := os.Open(path)
294 if err != nil {
295 return nil, fmt.Errorf("imagecache: open download: %w", err)
296 }
297 img, err := qcow2reader.Open(f)
298 if err != nil {
299 f.Close()
300 return nil, permanentf("imagecache: %s: %v", url, err)
301 }
302 if err := img.Readable(); err != nil {
303 img.Close()
304 f.Close()
305 return nil, permanentf("imagecache: %s is a %s image eitri cannot read: %v", url, img.Type(), err)
306 }
307 return &imageFile{Image: img, f: f}, nil
308 }
309
310 // imageFile ties the decoded image to the file underneath it, so one Close
311 // releases both.
312 type imageFile struct {
313 image.Image
314 f *os.File
315 }
316
317 func (i *imageFile) Close() error {
318 err := i.Image.Close()
319 if ferr := i.f.Close(); err == nil {
320 err = ferr
321 }
322 return err
323 }
324
325 // decode writes the disk image at src out as raw at dst. The decoder skips
326 // unallocated and all-zero extents rather than materialising them, so the
327 // result is sparse for the same reason the gzip path's writer produces one —
328 // but exactly, from the image's own extent map, instead of by scanning for
329 // zeros.
330 //
331 // The closing Truncate is load-bearing in the same way it is there: Convert
332 // never writes a trailing zero extent, so without it an image ending in zeros
333 // lands shorter than it claims, and PrepareRootDisk sizes a guest's disk
334 // against that number.
335 func decode(url, src, dst string) error {
336 img, err := openImage(url, src)
337 if err != nil {
338 return err
339 }
340 defer img.Close()
341
342 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
343 if err != nil {
344 return fmt.Errorf("imagecache: create image: %w", err)
345 }
346 if err := convert.Convert(out, img, convert.Options{}); err != nil {
347 out.Close()
348 return fmt.Errorf("imagecache: decode %s: %w", url, err)
349 }
350 if err := out.Truncate(img.Size()); err != nil {
351 out.Close()
352 return fmt.Errorf("imagecache: size image: %w", err)
353 }
354 if err := out.Close(); err != nil {
355 return fmt.Errorf("imagecache: close image: %w", err)
270 } 356 }
271 return nil 357 return nil
272 } 358 }
273 359
274 // SweepTemps removes abandoned download and convert temporaries — the two temp 360 // SweepTemps removes abandoned download and decode temporaries — the two temp
275 // forms Ensure writes before its atomic rename. Neither matches evict's "*.raw" 361 // forms Ensure writes before its atomic rename. Neither matches evict's "*.raw"
276 // glob, so nothing else ever reclaims them, and an agent killed mid-fetch (the 362 // glob, so nothing else ever reclaims them, and an agent killed mid-fetch (the
277 // deploy path SIGTERMs it) leaves one behind per in-flight image. 363 // deploy path SIGTERMs it) leaves one behind per in-flight image.
internal/agent/imagecache/imagecache_test.go
Old New
@@ -6,7 +6,6 @@ import (
6 "context" 6 "context"
7 "crypto/sha256" 7 "crypto/sha256"
8 "encoding/hex" 8 "encoding/hex"
9 "fmt"
10 "net/http" 9 "net/http"
11 "net/http/httptest" 10 "net/http/httptest"
12 "os" 11 "os"
@@ -15,38 +14,33 @@ import (
15 "testing" 14 "testing"
16 "time" 15 "time"
17 16
18 "github.com/a73x/eitri/internal/agent/exec"
19 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require" 18 "github.com/stretchr/testify/require"
21 ) 19 )
22 20
23 // fake qemu-img: just copies src to dst (args: convert -O raw src dst) 21 // tinyQcow2 is a real qcow2 image — 256 KiB virtual, 4 KiB clusters — holding
24 func fakeRunner(t *testing.T) exec.Runner { 22 // data in its first cluster, a long run of zeros, and four bytes at the very
25 return func(ctx context.Context, name string, args ...string) (string, error) { 23 // end. Real because the decoder parses headers for a living: a handmade body
26 t.Helper() 24 // carrying only the magic number would be rejected as unparsable and would
27 if name != "qemu-img" { 25 // exercise the error path while claiming to exercise the decode path.
28 return "", fmt.Errorf("unexpected command %s", name) 26 //
29 } 27 // tinyRaw rebuilds what it must decode to, so the assertion is byte-for-byte
30 src, dst := args[len(args)-2], args[len(args)-1] 28 // rather than "some file appeared".
31 data, err := os.ReadFile(src) 29 func tinyQcow2(t *testing.T) []byte {
32 if err != nil { 30 t.Helper()
33 return "", err 31 b, err := os.ReadFile(filepath.Join("testdata", "tiny.qcow2"))
34 } 32 require.NoError(t, err)
35 return "", os.WriteFile(dst, data, 0o644) 33 return b
36 }
37 } 34 }
38 35
39 // noRunner fails the test if anything shells out. It is how "no tool was 36 func tinyRaw() []byte {
40 // needed" is asserted rather than assumed: a raw image must be materialized by 37 raw := make([]byte, 256<<10)
41 // the cache itself, on a host that may have no qemu-img at all. 38 copy(raw, []byte{0xeb, 0x63, 0x90, 0x00})
42 // It reports through Errorf and an error return rather than Fatalf: Ensure runs 39 for i := 4096; i < 8192; i++ {
43 // its body on a singleflight goroutine, and Fatalf's Goexit there would strand 40 raw[i] = 0x5A
44 // the caller waiting on a result that never arrives.
45 func noRunner(t *testing.T) exec.Runner {
46 return func(_ context.Context, name string, _ ...string) (string, error) {
47 t.Errorf("no subprocess expected, got %q", name)
48 return "", fmt.Errorf("unexpected subprocess %q", name)
49 } 41 }
42 copy(raw[len(raw)-4:], []byte("ETRI"))
43 return raw
50 } 44 }
51 45
52 func serve(t *testing.T, body []byte) (*httptest.Server, string) { 46 func serve(t *testing.T, body []byte) (*httptest.Server, string) {
@@ -69,7 +63,7 @@ func TestEnsureDownloadsVerifiesAndCaches(t *testing.T) {
69 t.Cleanup(ts.Close) 63 t.Cleanup(ts.Close)
70 sum := sha256.Sum256(body) 64 sum := sha256.Sum256(body)
71 sha := hex.EncodeToString(sum[:]) 65 sha := hex.EncodeToString(sum[:])
72 c := New(t.TempDir(), noRunner(t)) 66 c := New(t.TempDir())
73 67
74 p1, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha) 68 p1, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha)
75 require.NoError(t, err) 69 require.NoError(t, err)
@@ -81,14 +75,13 @@ func TestEnsureDownloadsVerifiesAndCaches(t *testing.T) {
81 assert.Equal(t, int64(1), downloads.Load(), "second Ensure must hit the cache, not re-fetch") 75 assert.Equal(t, int64(1), downloads.Load(), "second Ensure must hit the cache, not re-fetch")
82 } 76 }
83 77
84 // TestEnsureMaterializesRawWithoutATool is the reason this path exists: vfkit 78 // TestEnsureMaterializesRawByRename pins the cheapest path: a download that is
85 // wants a raw disk and macOS ships no qemu-img, so a raw image has to become 79 // already a raw image becomes the cache entry with a rename, paying for neither
86 // the cache entry with nothing but a rename. noRunner turns "no tool" from a 80 // a decode pass nor a second copy of a multi-gigabyte file.
87 // claim into an assertion. 81 func TestEnsureMaterializesRawByRename(t *testing.T) {
88 func TestEnsureMaterializesRawWithoutATool(t *testing.T) {
89 body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 1024) // no magic number: raw 82 body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 1024) // no magic number: raw
90 ts, sum := serve(t, body) 83 ts, sum := serve(t, body)
91 c := New(t.TempDir(), noRunner(t)) 84 c := New(t.TempDir())
92 85
93 p, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum) 86 p, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum)
94 require.NoError(t, err) 87 require.NoError(t, err)
@@ -99,26 +92,48 @@ func TestEnsureMaterializesRawWithoutATool(t *testing.T) {
99 assert.Equal(t, sum+".raw", filepath.Base(p)) 92 assert.Equal(t, sum+".raw", filepath.Base(p))
100 } 93 }
101 94
102 // TestEnsureStillConvertsQcow2 pins that sniffing routes the other way too: 95 // TestEnsureDecodesQcow2Natively is the point of the decoder: qcow2 becomes a
103 // qcow2 keeps the tool, so this change adds a path rather than replacing one. 96 // format every host can read, not one that needs a tool the host may not have.
104 func TestEnsureStillConvertsQcow2(t *testing.T) { 97 // The result must be the exact raw image, offsets and trailing bytes included.
105 body := append([]byte{'Q', 'F', 'I', 0xfb}, bytes.Repeat([]byte{0x11}, 512)...) 98 func TestEnsureDecodesQcow2Natively(t *testing.T) {
106 ts, sum := serve(t, body) 99 ts, sum := serve(t, tinyQcow2(t))
107 var calls int 100 c := New(t.TempDir())
108 c := New(t.TempDir(), func(ctx context.Context, name string, args ...string) (string, error) {
109 calls++
110 return fakeRunner(t)(ctx, name, args...)
111 })
112 101
113 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 102 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
114 require.NoError(t, err) 103 require.NoError(t, err)
115 assert.FileExists(t, p) 104
116 assert.Equal(t, 1, calls, "qcow2 must still go through the converter") 105 got, err := os.ReadFile(p)
106 require.NoError(t, err)
107 assert.True(t, bytes.Equal(tinyRaw(), got), "qcow2 must decode to the exact raw image")
108
109 fi, err := os.Stat(p)
110 require.NoError(t, err)
111 assert.Equal(t, int64(256<<10), fi.Size(),
112 "an image ending in zeros must keep its apparent size")
113 }
114
115 // TestEnsureRejectsUnparsableImagePermanently pins the other half: a header the
116 // decoder cannot parse is a property of the artifact, since its checksum has
117 // already matched, so retrying it can only fail the same way.
118 func TestEnsureRejectsUnparsableImagePermanently(t *testing.T) {
119 ts, sum := serve(t, append([]byte{'Q', 'F', 'I', 0xfb}, bytes.Repeat([]byte{0x11}, 512)...))
120 dir := t.TempDir()
121 c := New(dir)
122
123 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
124 require.Error(t, err)
125 var perm interface{ Permanent() bool }
126 require.ErrorAs(t, err, &perm, "a broken container header must be marked permanent")
127 assert.True(t, perm.Permanent())
128
129 entries, err := os.ReadDir(dir)
130 require.NoError(t, err)
131 assert.Empty(t, entries, "a rejected image must leave nothing in the cache")
117 } 132 }
118 133
119 func TestEnsureRejectsChecksumMismatch(t *testing.T) { 134 func TestEnsureRejectsChecksumMismatch(t *testing.T) {
120 ts, _ := serve(t, []byte("evil-bytes")) 135 ts, _ := serve(t, []byte("evil-bytes"))
121 c := New(t.TempDir(), fakeRunner(t)) 136 c := New(t.TempDir())
122 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", "0000000000000000000000000000000000000000000000000000000000000000") 137 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", "0000000000000000000000000000000000000000000000000000000000000000")
123 assert.Error(t, err, "tampered image must be rejected before conversion") 138 assert.Error(t, err, "tampered image must be rejected before conversion")
124 } 139 }
@@ -126,7 +141,7 @@ func TestEnsureRejectsChecksumMismatch(t *testing.T) {
126 // --- M4: sha path traversal guard --- 141 // --- M4: sha path traversal guard ---
127 142
128 func TestEnsureRejectsInvalidSha(t *testing.T) { 143 func TestEnsureRejectsInvalidSha(t *testing.T) {
129 c := New(t.TempDir(), fakeRunner(t)) 144 c := New(t.TempDir())
130 145
131 t.Run("path traversal", func(t *testing.T) { 146 t.Run("path traversal", func(t *testing.T) {
132 _, err := c.Ensure(context.Background(), "http://unused", "../../etc/passwd") 147 _, err := c.Ensure(context.Background(), "http://unused", "../../etc/passwd")
@@ -149,10 +164,10 @@ func TestEnsureRejectsInvalidSha(t *testing.T) {
149 // --- I1: atomic conversion via temp file --- 164 // --- I1: atomic conversion via temp file ---
150 165
151 func TestEnsureAtomicConvert_LeftoverPartialIsIgnored(t *testing.T) { 166 func TestEnsureAtomicConvert_LeftoverPartialIsIgnored(t *testing.T) {
152 // Genuinely qcow2: only the converting path builds the temp this test is 167 // Genuinely qcow2: only the decoding path builds the temp this test is named
153 // named for. A body without a magic number is raw, and raw renames the 168 // for. A body no container claims is raw, and raw renames the download
154 // download straight onto final without ever creating one. 169 // straight onto final without ever creating one.
155 body := append([]byte{'Q', 'F', 'I', 0xfb}, []byte("pretend-qcow2-image")...) 170 body := tinyQcow2(t)
156 ts, sum := serve(t, body) 171 ts, sum := serve(t, body)
157 dir := t.TempDir() 172 dir := t.TempDir()
158 173
@@ -160,21 +175,21 @@ func TestEnsureAtomicConvert_LeftoverPartialIsIgnored(t *testing.T) {
160 partial := filepath.Join(dir, sum+".raw.converting") 175 partial := filepath.Join(dir, sum+".raw.converting")
161 require.NoError(t, os.WriteFile(partial, []byte("corrupt partial"), 0o644)) 176 require.NoError(t, os.WriteFile(partial, []byte("corrupt partial"), 0o644))
162 177
163 c := New(dir, fakeRunner(t)) 178 c := New(dir)
164 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 179 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
165 require.NoError(t, err) 180 require.NoError(t, err)
166 181
167 // The result must contain the correct data (not the corrupt partial). 182 // The result must contain the decoded image, not the corrupt partial.
168 got, err := os.ReadFile(p) 183 got, err := os.ReadFile(p)
169 require.NoError(t, err) 184 require.NoError(t, err)
170 assert.Equal(t, body, got, "final cache file must contain correct image data") 185 assert.True(t, bytes.Equal(tinyRaw(), got), "final cache file must hold the decoded image")
171 } 186 }
172 187
173 func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) { 188 func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) {
174 body := []byte("pretend-qcow2-image") 189 body := []byte("pretend-qcow2-image")
175 ts, sum := serve(t, body) 190 ts, sum := serve(t, body)
176 dir := t.TempDir() 191 dir := t.TempDir()
177 c := New(dir, fakeRunner(t)) 192 c := New(dir)
178 193
179 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum) 194 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
180 require.NoError(t, err) 195 require.NoError(t, err)
@@ -206,7 +221,7 @@ func backdate(t *testing.T, path string, d time.Duration) {
206 // Ensure; the freshly-ensured image always survives. 221 // Ensure; the freshly-ensured image always survives.
207 func TestEnsureEvictsLRUBeyondCap(t *testing.T) { 222 func TestEnsureEvictsLRUBeyondCap(t *testing.T) {
208 dir := t.TempDir() 223 dir := t.TempDir()
209 c := New(dir, fakeRunner(t)) 224 c := New(dir)
210 c.MaxBytes = 150 225 c.MaxBytes = 150
211 226
212 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100)) 227 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
@@ -223,7 +238,7 @@ func TestEnsureEvictsLRUBeyondCap(t *testing.T) {
223 // everything (existing behavior). 238 // everything (existing behavior).
224 func TestEnsureCapZeroDisablesEviction(t *testing.T) { 239 func TestEnsureCapZeroDisablesEviction(t *testing.T) {
225 dir := t.TempDir() 240 dir := t.TempDir()
226 c := New(dir, fakeRunner(t)) 241 c := New(dir)
227 242
228 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100)) 243 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
229 backdate(t, a, time.Hour) 244 backdate(t, a, time.Hour)
@@ -236,7 +251,7 @@ func TestEnsureCapZeroDisablesEviction(t *testing.T) {
236 // file so frequently-used images sort as recent. 251 // file so frequently-used images sort as recent.
237 func TestCacheHitRefreshesRecency(t *testing.T) { 252 func TestCacheHitRefreshesRecency(t *testing.T) {
238 dir := t.TempDir() 253 dir := t.TempDir()
239 c := New(dir, fakeRunner(t)) 254 c := New(dir)
240 255
241 body := bytes.Repeat([]byte("a"), 50) 256 body := bytes.Repeat([]byte("a"), 50)
242 a := ensureBytes(t, c, body) 257 a := ensureBytes(t, c, body)
@@ -263,7 +278,7 @@ func TestSweepTempsRemovesAbandonedTempsOnly(t *testing.T) {
263 require.NoError(t, os.WriteFile(p, []byte("bytes"), 0o644)) 278 require.NoError(t, os.WriteFile(p, []byte("bytes"), 0o644))
264 } 279 }
265 280
266 New(dir, fakeRunner(t)).SweepTemps() 281 New(dir).SweepTemps()
267 282
268 _, err := os.Stat(download) 283 _, err := os.Stat(download)
269 assert.True(t, os.IsNotExist(err), "abandoned download temp must be swept") 284 assert.True(t, os.IsNotExist(err), "abandoned download temp must be swept")
@@ -277,7 +292,7 @@ func TestSweepTempsRemovesAbandonedTempsOnly(t *testing.T) {
277 // the just-ensured image survives even when it alone exceeds MaxBytes. 292 // the just-ensured image survives even when it alone exceeds MaxBytes.
278 func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) { 293 func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) {
279 dir := t.TempDir() 294 dir := t.TempDir()
280 c := New(dir, fakeRunner(t)) 295 c := New(dir)
281 c.MaxBytes = 10 296 c.MaxBytes = 10
282 297
283 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100)) 298 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
@@ -303,7 +318,7 @@ func TestConcurrentEnsureFetchesOnce(t *testing.T) {
303 })) 318 }))
304 defer srv.Close() 319 defer srv.Close()
305 320
306 c := New(t.TempDir(), fakeRunner(t)) 321 c := New(t.TempDir())
307 322
308 const callers = 4 323 const callers = 4
309 paths := make(chan string, callers) 324 paths := make(chan string, callers)
@@ -372,7 +387,7 @@ func TestEnsureDecompressesGzippedRaw(t *testing.T) {
372 ) 387 )
373 ts, sum := serve(t, gzipped(t, raw)) 388 ts, sum := serve(t, gzipped(t, raw))
374 dir := t.TempDir() 389 dir := t.TempDir()
375 c := New(dir, noRunner(t)) 390 c := New(dir)
376 391
377 p, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum) 392 p, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum)
378 require.NoError(t, err) 393 require.NoError(t, err)
@@ -395,7 +410,7 @@ func TestEnsureDecompressesGzippedRaw(t *testing.T) {
395 // re-downloading them cannot produce a different outcome. 410 // re-downloading them cannot produce a different outcome.
396 func TestEnsureRejectsCorruptGzipPermanently(t *testing.T) { 411 func TestEnsureRejectsCorruptGzipPermanently(t *testing.T) {
397 ts, sum := serve(t, []byte{0x1f, 0x8b, 'g', 'a', 'r', 'b', 'a', 'g', 'e', '!'}) 412 ts, sum := serve(t, []byte{0x1f, 0x8b, 'g', 'a', 'r', 'b', 'a', 'g', 'e', '!'})
398 c := New(t.TempDir(), noRunner(t)) 413 c := New(t.TempDir())
399 414
400 _, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum) 415 _, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum)
401 require.Error(t, err) 416 require.Error(t, err)
@@ -412,7 +427,7 @@ func TestEnsureRejectsCorruptGzipPermanently(t *testing.T) {
412 func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) { 427 func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) {
413 ts, sum := serve(t, gzipped(t, qcow2(bytes.Repeat([]byte{0x42}, 4096)))) 428 ts, sum := serve(t, gzipped(t, qcow2(bytes.Repeat([]byte{0x42}, 4096))))
414 dir := t.TempDir() 429 dir := t.TempDir()
415 c := New(dir, noRunner(t)) 430 c := New(dir)
416 431
417 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2.gz", sum) 432 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2.gz", sum)
418 require.Error(t, err) 433 require.Error(t, err)
internal/agent/imagecache/testdata/tiny.qcow2
Old New
Binary file
Binary files differ
internal/agent/reconcile/reconcile.go
Old New
@@ -650,7 +650,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
650 650
651 // Throttle: cap how many VMs on this host may be inside the I/O-heavy part 651 // Throttle: cap how many VMs on this host may be inside the I/O-heavy part
652 // of create at once. Per-VM workers made these concurrent — N simultaneous 652 // of create at once. Per-VM workers made these concurrent — N simultaneous
653 // creates mean N image downloads, N qemu-img converts and N multi-GB disk 653 // creates mean N image downloads, N image decodes and N multi-GB disk
654 // copies against one device, which can starve the state dir that Step scans 654 // copies against one device, which can starve the state dir that Step scans
655 // every tick (the heartbeat's one remaining blocking path) and can exhaust 655 // every tick (the heartbeat's one remaining blocking path) and can exhaust
656 // disk on the in-flight temporaries alone. Waiting here is free: the worker 656 // disk on the in-flight temporaries alone. Waiting here is free: the worker
internal/agent/run/cli.go
Old New
@@ -214,7 +214,7 @@ func serve(st *state.Store, cfg Config) error {
214 slog.Warn("state load failed; surviving VMs' consoles will be silent until reconcile restarts them", "err", err) 214 slog.Warn("state load failed; surviving VMs' consoles will be silent until reconcile restarts them", "err", err)
215 } 215 }
216 216
217 cache := imagecache.New(st.ImagesDir(), hostRunner) 217 cache := imagecache.New(st.ImagesDir())
218 // Clamp before shifting: GB<<30 overflows int64 for absurd flag values — 218 // Clamp before shifting: GB<<30 overflows int64 for absurd flag values —
219 // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond 219 // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond
220 // any real cache; anything above disables eviction just like 0 would. 220 // any real cache; anything above disables eviction just like 0 would.