b4794065
feat(agent): guest images are decoded in-process, not by a tool
a73x 2026-08-06 09:12
Commit message
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. |