Skip to content

Commit f39b072

Browse files
committed
[CELL-293] cell nix-store push|pull — replace crane CLI in the cache pipeline with native Go subcommands so the local test and CI workflow share one code path; encoding regressions can't recur silently
- feat(cli): add `cell nix-store push` + `cell nix-store pull` — streams a /nix volume to/from an OCI registry as a single tar+gzip layer. Push uses `pkg/v1/stream.NewLayer` to upload uncompressed-tar stdin straight to the registry's chunked-upload API with the digest computed incrementally (no temp file, no disk staging — peak runner disk drops from ~11 GB to ~64 KB). Pull resolves the manifest, streams the last layer's gunzipped bytes, and extracts into either a Docker volume (`--volume`) or a host directory (`--dir`), with `--strip-components` matching `tar --strip-components=N` (defaults to 1 — workflow's prior behavior). The on-wire layer is always single-gzipped: deterministic regardless of base-image schema, eliminates the `crane append --new_layer -` double-gzip + disk-buffer failure modes that caused six CELL-292 iterations - fix(infra): rewrite the cache-publish step in `.github/workflows/build.dev.yml` to pipe uncompressed tar into `./bin/cell nix-store push` instead of staging an 11 GB tarball to `$RUNNER_TEMP` for `crane append --new_layer FILE`. Workflow now publishes via the same Go binary the local test invokes, so a green `task test:cache` is a faithful regression net for this step - fix(infra): rewrite both pull paths in `.github/workflows/build.dev.yml` (docker-build warm-start + docker-test stream-pull) to use `./bin/cell nix-store pull --volume VOL` instead of `crane blob | gunzip | docker run alpine tar -x --strip-components=1`. The docker-test job now builds the cell binary too (matching docker-build), so push and pull go through identical bytes-on-disk code - fix(infra): port the same cache pipeline to `.github/workflows/build.release.yml` — replace the broken `actions/cache@v4` step (the v1 design CELL-292 abandoned because `_data` is root-owned and the cache action silently saved an empty archive) with `cell nix-store pull` hydrate + `cell nix-store push` republish. Release builds now share the `nix-cache2-${arch}-${HASH}` tag with dev builds, so any release that runs after a green dev build hits a populated cache instead of starting from an empty /nix volume - test(nixstore): table-driven unit tests against go-containerregistry's in-memory OCI registry (`internal/nixstore/{pull,push}_test.go`) — covers byte-for-byte round-trip, last-layer-only extraction, error paths on bad refs, and explicit assertion that the pushed layer is single-gzipped (`raw[0:2] == 1f8b` and `gunzip(layer)[0:4] != 1f8b`). 6/6 PASS - test(integration): `test/cache_roundtrip_test.go` invokes `./bin/cell nix-store push|pull` via `exec.Command` instead of shell-piping `crane append`/`crane blob` — same binary as the CI workflow, so test ≡ production. End-to-end PASS: 198,643 files round-tripped byte-for-byte through the actual subcommands
1 parent 40df48e commit f39b072

8 files changed

Lines changed: 1101 additions & 157 deletions

File tree

‎.github/workflows/build.dev.yml‎

Lines changed: 61 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,16 @@ jobs:
103103
- name: Build cell binary
104104
run: task cell:build
105105

106-
# crane = google/go-containerregistry CLI. We use it to stream the
107-
# nix-store volume directly to/from a GHCR layer blob without going
108-
# through `docker buildx` or `docker pull`'s overlay2 extract.
109-
# Push: tar -czf - | crane append → registry (no 32 GB tar on disk).
110-
# Pull: crane blob | gunzip | tar -x → volume (no overlay2 extract).
111-
# Net: ~32 GB less peak transient disk per job vs the Docker-native
112-
# round-trip.
106+
# Cache push/pull goes through `cell nix-store {push,pull}` — a
107+
# cell subcommand using `pkg/v1/stream.NewLayer` from
108+
# go-containerregistry. Replaces the `crane` CLI usage that
109+
# required either file staging (~11 GB temp file) or accepted
110+
# double-gzipped layers (CELL-292 iteration history). With the
111+
# cell subcommand, the layer is streamed (no temp file) and
112+
# always single-gzipped on the wire — see CELL-293.
113+
# crane is still installed for the manifest size-probe in the
114+
# warm-start guard; can be removed once `cell nix-store inspect`
115+
# lands.
113116
- name: Install crane
114117
run: |
115118
set -eo pipefail
@@ -156,23 +159,16 @@ jobs:
156159
exit 0
157160
fi
158161
echo "cache HIT: $IMG"
159-
# The appended nix-store layer is the LAST one (atop the busybox
160-
# base). Stream the blob → ONE gunzip → tar -x into the volume.
161-
# Single-gunzip is correct because the push step writes the
162-
# gzipped tar to a FILE first and passes the file to
163-
# `crane append --new_layer FILE` (crane detects gzip magic in
164-
# the file content and stores it as-is, single-gzipped on the
165-
# registry). Earlier `tar -czf | crane append --new_layer -`
166-
# stdin pipeline produced a double-gzipped layer on amd64
167-
# (CI #217 failure mode); file-based push is deterministic.
168-
LAYER_DIGEST=$(crane manifest "$IMG" | jq -r '.layers[-1].digest')
162+
# `cell nix-store pull` fetches the LAST layer of $IMG (atop
163+
# the busybox base) and streams it directly into the named
164+
# Docker volume — internal gunzip + tar -x, no separate
165+
# shell pipe. Replaces the prior
166+
# `crane blob | gunzip | docker run alpine tar -x` chain.
169167
docker volume rm devcell-nix-store-${{ matrix.arch }} 2>/dev/null || true
170168
docker volume create devcell-nix-store-${{ matrix.arch }} >/dev/null
171-
crane blob "$IMG@$LAYER_DIGEST" \
172-
| gunzip \
173-
| docker run --rm -i \
174-
-v devcell-nix-store-${{ matrix.arch }}:/dest \
175-
alpine sh -c 'cd /dest && tar -x --strip-components=1'
169+
./bin/cell nix-store pull \
170+
--image "$IMG" \
171+
--volume "devcell-nix-store-${{ matrix.arch }}"
176172
docker run --rm -v devcell-nix-store-${{ matrix.arch }}:/nix:ro alpine \
177173
sh -c 'echo "hydrated $(du -sh /nix | cut -f1)"'
178174
@@ -264,72 +260,37 @@ jobs:
264260
HB_PID=$!
265261
trap "kill $HB_PID 2>/dev/null || true" EXIT
266262
267-
# Stream gzipped tar from the volume → pv (throughput monitor) →
268-
# crane → registry as a single OCI layer atop a busybox base.
269-
# crane uploads the layer blob in chunks while tar is still
270-
# emitting bytes, so the network side is streamed.
271-
# CRITICAL: emit gzipped tar (`-czf`, not `-cf`).
272-
# `crane append --new_layer -` buffers stdin to a temp file in
273-
# the runner's disk to compute the layer digest before it can
274-
# start the registry upload. Uncompressed `/nix` is ~32 GB —
275-
# that temp file exhausts the runner's disk and crashes the
276-
# runner worker itself (System.IO.IOException: No space left
277-
# on device on `/home/runner/extracted/_diag/Worker_*.log`,
278-
# observed on commit 5a930e3, both arches). With `-czf` the
279-
# pipe carries ~10 GB which fits the runner's free disk.
280-
# crane does NOT re-compress already-gzipped input — empirical
281-
# check: `crane blob ... | gunzip | head -c 4` on the resulting
282-
# layer yields tar magic (`nix/`), not a second gzip wrapper.
283-
# So pull side uses a single `gunzip | tar -x`.
284-
# `-C / nix` (instead of `-C /nix .` + --transform) produces
285-
# archive paths with the `nix/` prefix natively, so the layer's
286-
# files land at /nix/* inside the cache image (matched by
287-
# `--strip-components=1` on the pull side). We use this form
288-
# because BusyBox tar (the default in alpine) doesn't implement
289-
# GNU tar's --transform option.
290-
# --exclude: Unix sockets can't be archived — pre-exclude the
291-
# nix-daemon socket so tar doesn't emit a "socket ignored"
292-
# warning that could trip stricter tar implementations.
293-
# `pv -tabri 30 -f`: every 30s emit elapsed/avg-rate/cur-rate/
294-
# bytes-transferred to stderr (-f forces output to a non-TTY).
295-
# crane verbosity (`-v`) is DELIBERATELY OFF — when enabled it
296-
# logs one stderr line per HTTP chunk upload, which at full
297-
# upload speed flooded GitHub Actions' per-step log cap on
298-
# commit f592686.
263+
# Stream UNCOMPRESSED tar from the volume → `cell nix-store
264+
# push` → registry. The cell subcommand uses
265+
# `pkg/v1/stream.NewLayer` from go-containerregistry: it
266+
# reads stdin lazily, gzips on the fly, and uploads via the
267+
# OCI chunked-upload protocol with the digest computed
268+
# incrementally. Peak runner disk for this step: ~64 KB
269+
# (gzip window buffer + HTTP chunk buffer). The on-wire
270+
# layer is SINGLE-gzipped — the test/cache_roundtrip_test
271+
# asserts this exactly, so any regression here surfaces in
272+
# ~5 min locally instead of ~30 min of CI.
273+
# `tar -cf` not `-czf`: cell nix-store push gzips for us.
274+
# `-C / nix` (instead of `-C /nix .` + --transform) lands
275+
# archive entries under `nix/...` natively (BusyBox tar
276+
# doesn't implement --transform).
277+
# `--exclude=nix/var/nix/daemon-socket`: Unix sockets can't
278+
# be archived.
279+
# `pv -tabri 30 -f`: every 30s emit throughput line.
299280
echo "==== Publish start ($(date -u +%H:%M:%SZ)) ===="
300281
SECONDS=0
301282
302-
# Stage tar.gz to a temp file FIRST, then pass the file to
303-
# `crane append --new_layer FILE`. Why file instead of stdin:
304-
# `crane append --new_layer -` treats stdin as an uncompressed
305-
# tarball and gzip-wraps it on upload, even if it's already
306-
# gzipped — so `tar -czf | crane append --new_layer -` lands
307-
# a double-gzipped layer on the registry. The file-based path
308-
# detects gzip magic in the file content and stores it as-is.
309-
# This makes the on-wire encoding deterministic (single-gzip)
310-
# so the pull side's `crane blob | gunzip | tar -x` works
311-
# without guessing how many gunzip passes to apply. Mirrors
312-
# `test/cache_roundtrip_test.go`'s push exactly so the local
313-
# test is a faithful regression net for this step.
314-
# Disk cost: one ~11 GB temp file on the runner; runner has
315-
# ~60 GB free after the Free-disk-space step, comfortable.
316-
# crane append's `--new_tag` is `string` (singular), not
317-
# `strings` — passing it twice silently overwrites, so push
318-
# $LATEST then alias $EXACT via `crane tag` (manifest-only,
319-
# no re-upload).
320-
CACHE_TAR="${RUNNER_TEMP:-/tmp}/nix-cache-${{ matrix.arch }}.tar.gz"
321283
docker run --rm -v devcell-nix-store-${{ matrix.arch }}:/nix:ro alpine \
322-
tar -czf - \
284+
tar -cf - \
323285
--exclude='nix/var/nix/daemon-socket' \
324286
-C / nix \
325287
| pv -tabri 30 -f -N tar-bytes \
326-
> "$CACHE_TAR"
327-
echo "staged tarball: $(stat -c%s "$CACHE_TAR") bytes at $CACHE_TAR"
328-
crane append \
329-
--base public.ecr.aws/docker/library/busybox:latest \
330-
--new_layer "$CACHE_TAR" \
331-
--new_tag "$LATEST"
332-
rm -f "$CACHE_TAR"
288+
| ./bin/cell nix-store push \
289+
--base public.ecr.aws/docker/library/busybox:latest \
290+
--image "$LATEST"
291+
# Alias to the hash-pinned tag via crane (manifest-only,
292+
# no re-upload). Could be replaced with a `cell nix-store
293+
# tag` subcommand in a follow-up.
333294
crane tag "$LATEST" "nix-cache2-${{ matrix.arch }}-${HASH}"
334295
335296
echo
@@ -383,6 +344,18 @@ jobs:
383344
go-version-file: go.mod
384345
cache-dependency-path: go.sum
385346

347+
- name: Install go-task
348+
uses: arduino/setup-task@v2
349+
with:
350+
version: 3.x
351+
repo-token: ${{ secrets.GITHUB_TOKEN }}
352+
353+
# Build cell so we can use `cell nix-store pull` below — same
354+
# binary docker-build's publish step uses, so any encoding
355+
# divergence between push and pull is structurally impossible.
356+
- name: Build cell binary
357+
run: task cell:build
358+
386359
- name: Install crane
387360
run: |
388361
set -eo pipefail
@@ -396,12 +369,10 @@ jobs:
396369
| sudo tar -xzC /usr/local/bin crane
397370
crane version
398371
399-
# Stream-pull the nix-store cache image's layer blob directly into the
400-
# per-arch volume via crane, bypassing Docker's overlay2 extract +
401-
# auto-seed copy (~32 GB less peak transient disk vs `docker pull`).
402-
# Single seed: we set DEVCELL_NIX_VOLUME on the test step below so
403-
# `cell shell` / `cell claude` use this per-arch volume directly
404-
# instead of the default-named one — no need to seed two volumes.
372+
# Stream-pull the nix-store cache image's last layer into the
373+
# per-arch volume via `cell nix-store pull` — same binary the
374+
# publish step in docker-build uses, so push/pull encoding
375+
# divergence is impossible by construction.
405376
- name: Stream-pull /nix cache from GHCR into volume
406377
run: |
407378
set -eo pipefail
@@ -429,18 +400,11 @@ jobs:
429400
exit 0
430401
fi
431402
echo "cache HIT: $IMG"
432-
# ONE gunzip: the publish step writes gzipped tar to a FILE
433-
# and uses `crane append --new_layer FILE`, which stores the
434-
# file content as-is (single-gzipped on the registry).
435-
# Mirrors the warm-start step in docker-build.
436-
LAYER_DIGEST=$(crane manifest "$IMG" | jq -r '.layers[-1].digest')
437403
docker volume rm devcell-nix-store-${{ matrix.arch }} 2>/dev/null || true
438404
docker volume create devcell-nix-store-${{ matrix.arch }} >/dev/null
439-
crane blob "$IMG@$LAYER_DIGEST" \
440-
| gunzip \
441-
| docker run --rm -i \
442-
-v devcell-nix-store-${{ matrix.arch }}:/dest \
443-
alpine sh -c 'cd /dest && tar -x --strip-components=1'
405+
./bin/cell nix-store pull \
406+
--image "$IMG" \
407+
--volume "devcell-nix-store-${{ matrix.arch }}"
444408
445409
- name: Show volume status (debug)
446410
run: |

‎cmd/nix_store.go‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package main
2+
3+
// `cell nix-store` — push/pull a /nix volume as an OCI layer against a
4+
// remote registry. Replaces the `crane` CLI usage in
5+
// .github/workflows/build.dev.yml so the local cache-roundtrip test
6+
// and the CI workflow share a single Go code path (CELL-293).
7+
//
8+
// Currently implements `pull`. Push lands once pull is validated end
9+
// to end (see CELL-293's TDD ordering).
10+
11+
import (
12+
"io"
13+
"os"
14+
15+
"github.com/DimmKirr/devcell/internal/nixstore"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var nixStoreCmd = &cobra.Command{
20+
Use: "nix-store",
21+
Short: "Push or pull a /nix volume as an OCI layer (cache pipeline)",
22+
}
23+
24+
var nixStorePullCmd = &cobra.Command{
25+
Use: "pull",
26+
Short: "Pull the last layer of an OCI image into a Docker volume or directory",
27+
Long: `Pull downloads the LAST layer of the given OCI image and extracts the
28+
gzipped tarball into either a Docker volume (--volume) or a host
29+
directory (--dir).
30+
31+
Layer selection: we only extract the topmost layer because the
32+
production cache image is structured as a busybox base + one nix-store
33+
tar layer. Earlier layers are skipped.
34+
35+
Streaming: the layer's bytes are gunzipped + tar-extracted lazily as
36+
they arrive, so peak memory stays near the gzip window size (~32 KB)
37+
regardless of layer size.
38+
39+
Examples:
40+
cell nix-store pull --image ghcr.io/org/repo:nix-cache-amd64-latest --volume devcell-nix-store
41+
cell nix-store pull --image ghcr.io/org/repo:nix-cache-amd64-latest --dir /tmp/cache-extract`,
42+
RunE: runNixStorePull,
43+
}
44+
45+
var nixStorePushCmd = &cobra.Command{
46+
Use: "push",
47+
Short: "Stream an uncompressed tar from stdin into a registry as a single OCI tar+gzip layer",
48+
Long: `Push reads an UNCOMPRESSED tar from stdin and uploads it as a single
49+
OCI tar+gzip layer atop --base, tagged --image, to the destination
50+
registry.
51+
52+
Streaming: the bytes flow stdin → gzip → registry chunked upload with
53+
the digest computed incrementally. Peak memory ~32 KB (gzip window),
54+
no disk staging.
55+
56+
The on-wire layer is SINGLE-gzipped — this function is the
57+
deterministic alternative to ` + "`crane append --new_layer -`" + ` which
58+
re-gzipped pre-gzipped stdin.
59+
60+
Example:
61+
docker run --rm -v devcell-nix-store:/nix:ro alpine \
62+
tar -cf - --exclude='nix/var/nix/daemon-socket' -C / nix \
63+
| cell nix-store push \
64+
--base public.ecr.aws/docker/library/busybox:latest \
65+
--image ghcr.io/org/repo:nix-cache-amd64-latest`,
66+
RunE: runNixStorePush,
67+
}
68+
69+
func init() {
70+
nixStorePullCmd.Flags().String("image", "", "OCI image reference to pull (e.g. ghcr.io/org/repo:tag)")
71+
nixStorePullCmd.Flags().String("volume", "", "Docker volume name to extract into (mutually exclusive with --dir)")
72+
nixStorePullCmd.Flags().String("dir", "", "Host directory to extract into (mutually exclusive with --volume)")
73+
nixStorePullCmd.Flags().Int("strip-components", 1, "Strip N leading path elements from archive entries (tar --strip-components semantics)")
74+
_ = nixStorePullCmd.MarkFlagRequired("image")
75+
76+
nixStorePushCmd.Flags().String("base", "", "OCI base image to layer atop (e.g. public.ecr.aws/docker/library/busybox:latest)")
77+
nixStorePushCmd.Flags().String("image", "", "destination OCI image reference (e.g. ghcr.io/org/repo:tag)")
78+
_ = nixStorePushCmd.MarkFlagRequired("base")
79+
_ = nixStorePushCmd.MarkFlagRequired("image")
80+
81+
nixStoreCmd.AddCommand(nixStorePullCmd)
82+
nixStoreCmd.AddCommand(nixStorePushCmd)
83+
rootCmd.AddCommand(nixStoreCmd)
84+
}
85+
86+
func runNixStorePush(cmd *cobra.Command, args []string) error {
87+
base, _ := cmd.Flags().GetString("base")
88+
image, _ := cmd.Flags().GetString("image")
89+
return nixstore.Push(cmd.Context(), base, image, io.NopCloser(os.Stdin))
90+
}
91+
92+
func runNixStorePull(cmd *cobra.Command, args []string) error {
93+
image, _ := cmd.Flags().GetString("image")
94+
volume, _ := cmd.Flags().GetString("volume")
95+
dir, _ := cmd.Flags().GetString("dir")
96+
strip, _ := cmd.Flags().GetInt("strip-components")
97+
98+
switch {
99+
case volume == "" && dir == "":
100+
return errFlag("must specify either --volume or --dir")
101+
case volume != "" && dir != "":
102+
return errFlag("--volume and --dir are mutually exclusive")
103+
case volume != "":
104+
return nixstore.PullToDockerVolume(cmd.Context(), image, volume, strip)
105+
default:
106+
return nixstore.Pull(cmd.Context(), image, dir, strip)
107+
}
108+
}
109+
110+
// errFlag wraps a flag-usage error in a way that cobra's `Use:` help
111+
// is printed alongside.
112+
func errFlag(msg string) error {
113+
return &flagError{msg: msg}
114+
}
115+
116+
type flagError struct{ msg string }
117+
118+
func (e *flagError) Error() string { return e.msg }

‎go.mod‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,13 @@ require (
4949
github.com/containerd/errdefs/pkg v0.3.0 // indirect
5050
github.com/containerd/log v0.1.0 // indirect
5151
github.com/containerd/platforms v0.2.1 // indirect
52+
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
5253
github.com/cpuguy83/dockercfg v0.3.2 // indirect
5354
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
5455
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
5556
github.com/distribution/reference v0.6.0 // indirect
57+
github.com/docker/cli v29.4.0+incompatible // indirect
58+
github.com/docker/docker-credential-helpers v0.9.3 // indirect
5659
github.com/docker/go-connections v0.6.0 // indirect
5760
github.com/docker/go-units v0.5.0 // indirect
5861
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -82,6 +85,7 @@ require (
8285
github.com/mailru/easyjson v0.7.7 // indirect
8386
github.com/mattn/go-localereader v0.0.1 // indirect
8487
github.com/mattn/go-runewidth v0.0.16 // indirect
88+
github.com/mitchellh/go-homedir v1.1.0 // indirect
8589
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
8690
github.com/moby/docker-image-spec v1.3.1 // indirect
8791
github.com/moby/go-archive v0.1.0 // indirect
@@ -118,6 +122,7 @@ require (
118122
github.com/tidwall/sjson v1.2.5 // indirect
119123
github.com/tklauser/go-sysconf v0.3.12 // indirect
120124
github.com/tklauser/numcpus v0.6.1 // indirect
125+
github.com/vbatts/tar-split v0.12.2 // indirect
121126
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
122127
github.com/x448/float16 v0.8.4 // indirect
123128
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

0 commit comments

Comments
 (0)