diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8c18002..457f988 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -279,3 +279,51 @@ in the "Language toolchain SDKs" group alongside the `MODULE.bazel` and `setup-p symlinks `python3`/`python` onto PATH. The CI `Devcontainer` job caches the built image in GHCR (`imageName`/`cacheFrom`, `push: filter` seeds it on pushes to main), so the feature layers are reused across runs rather than rebuilt cold; this needs the workflow's `packages: write`. + +## Devcontainer cache volumes + +Two named volumes carry derived state across a container rebuild, and both are mounted at a +cache **root** rather than at a per-tool directory: + +- `ud-cache` → `/home/vscode/.cache` +- `ud-go-pkg-cache` → `/go/pkg` (`$GOPATH/pkg`; `features/go` bakes `GOPATH=/go` into the image + ENV, so this is not under `$HOME` at all) + +**A mount too narrow loses caches silently.** It only persists the tools someone remembered to +enumerate, and nothing fails when one is missed — the container just rebuilds that cache every +time, which reads as "devcontainers are slow" rather than as a bug. That had already happened: +the mount was `~/.cache/bazel`, so its four siblings — `go-build` (967M), `bazelisk` (61M), +`pre-commit` (13M), `uv` — were rebuilt on every recreate. Narrow mounts also break outright +when the omitted sibling is not optional: mounting `/go/pkg/mod` rather than `/go/pkg` leaves +the checksum-db cache (`/go/pkg/sumdb`) out, and Docker creates the `/go/pkg` mountpoint parent +root-owned, so the first `go install` fails on `open /go/pkg/sumdb/…: no such file or +directory`. + +**A mount too wide shadows image content, also silently.** Docker seeds a named volume from the +image only while the volume is empty; after that the volume wins. So a volume over all of `/go` +would freeze `/go/bin` at whatever the image held on first mount, and a later `features/go` bump +would install tools nobody ever sees. `/go/bin` is image content — the feature builds ten tools +there at image-build time — while `/go/pkg` does not exist in the image at all, because the +feature purges the module cache afterwards. So `pkg/` is the derived half of GOPATH and `bin/` +is the artifact half, and only `pkg/` is mounted. `post-create.sh` reinstalls its six pinned +tools over the image's copies on every create, which is why persisting `/go/bin` would buy +nothing even without the shadowing. + +Nothing under either mount should stay ephemeral: every entry is content-addressed or +key-validated by its own tool, and CI builds cold, so a stale local cache can't reach `main`. + +Two couplings, both asserted by `.devcontainer/test_devcontainer_config.py`: + +- **post-create.sh must chown every volume target.** Docker attaches a volume root-owned unless + the image has a directory at the target to seed ownership from — which is exactly the + `/go/pkg` case — so a mount added without a chown entry is unwritable by `remoteUser`. The + chown is guarded on current ownership: warm, `~/.cache` is tens of GB, and recursing it on + every rebuild is minutes of re-asserting what is already right. +- **`$GOPATH/pkg` is not derivable from anything in this repo** — `GOPATH` is the go feature's + own value, baked into the image ENV — so the test pins it as a constant. + +The volumes are per-host, not per-worktree, and shared by every checkout of this repo. That is +fine for both: Bazel namespaces its output base by workspace path, and the module cache is +content-addressed. It does mean a mount-target change is visible from other worktrees' running +containers, which still mount the old volume at the old path — a volume's content is shared, its +mount point is per-container. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9774ab0..d401b77 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -44,8 +44,13 @@ // meta/devcontainer-base/scripts/ carry the per-step commentary. "initializeCommand": ".devcontainer/initialize.sh", "mounts": [ - "source=ud-bazel-cache,target=/home/vscode/.cache/bazel,type=volume", - "source=ud-go-cache,target=/home/vscode/go,type=volume", + // A volume per cache *root* — the whole of ~/.cache, not one tool's directory inside it. + // See ".claude/CLAUDE.md", "Devcontainer cache volumes". + "source=ud-cache,target=/home/vscode/.cache,type=volume", + // $GOPATH/pkg — the whole derived half of GOPATH (mod + sumdb), which the go feature puts + // at /go. Not all of /go: `bin/` there is image content a volume would shadow. Not + // /go/pkg/mod either: that leaves sumdb out and its root-owned parent breaks `go install`. + "source=ud-go-pkg-cache,target=/go/pkg,type=volume", // Bind the symlink (a static, workspace-relative SOURCE) to the static // container path the shared plumbing points the host-absolute path at. Docker // follows the symlink host-side, so this resolves to wherever the real git diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 403f2fa..09c4cd4 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -17,12 +17,22 @@ PLUMBING_WORKSPACE="$(cd "$_dc_here/.." && pwd)" \ PLUMBING_DIR="$_dc_here/.git-plumbing" \ /usr/local/bin/devcontainer-plumbing post-create -# Make the named-volume mounts writable by the non-root user. Docker attaches volumes -# root-owned on first mount, and the .cache parent of the bazel mount inherits that, so -# the chown covers .cache itself. postCreateCommand reruns on every rebuild, so this -# self-heals UID drift if remoteUser later changes (assuming the new user has sudo). If -# chown fails loudly here, recover with `docker volume rm ud-bazel-cache ud-go-cache`. -sudo chown -R "$(id -u):$(id -g)" "$HOME/.cache" "$HOME/go" +# Make the named-volume mounts writable by the non-root user: Docker attaches a volume +# root-owned unless the image has a directory at the target for it to seed ownership from. +# Every volume target in devcontainer.json needs an entry here — one without comes up +# unwritable — which test_devcontainer_config.py asserts. postCreateCommand reruns on every +# rebuild, so this self-heals UID drift if remoteUser later changes (assuming the new user has +# sudo). If chown fails loudly here, recover with `docker volume rm ud-cache ud-go-pkg-cache`. +# The image has no /go/pkg — the go feature purges the module cache after building its tools — +# so that mount is the one that actually arrives root-owned. +# +# Guarded rather than unconditional: warm, these hold tens of GB, and recursing them on every +# rebuild spends minutes re-asserting ownership that is already correct. +for volume_target in "$HOME/.cache" /go/pkg; do + if [ "$(stat -c '%u' "$volume_target")" != "$(id -u)" ]; then + sudo chown -R "$(id -u):$(id -g)" "$volume_target" + fi +done # Install golangci-lint. Version is pinned and tracked by Renovate (see renovate.json). # renovate: datasource=github-releases depName=golangci/golangci-lint diff --git a/.devcontainer/test_devcontainer_config.py b/.devcontainer/test_devcontainer_config.py index 2f23811..5d770a5 100644 --- a/.devcontainer/test_devcontainer_config.py +++ b/.devcontainer/test_devcontainer_config.py @@ -1,6 +1,6 @@ """Tests for the wiring between devcontainer.json, the Dockerfile, and the base image. -Three couplings live across those files and none of them fails loudly: +Four couplings live across those files and none of them fails loudly: - The `BASE_IMAGE` override has an exact working shape. Every nearby shape either breaks every local `devcontainer up` (an empty `--build-arg` overriding the Dockerfile default) @@ -10,8 +10,14 @@ - The lifecycle hooks call the command the base image installs. Calling the workspace copy instead still works here — the scripts are checked in — so the mistake would only surface in Syndic/.dotfiles, which has no such copy. - -The rationale for all three is in meta/devcontainer-base/README.md, "Consuming the image". + - Each cache volume is mounted at a cache *root*, and post-create.sh chowns exactly the set + of volume targets. Too narrow a target leaves the siblings ephemeral, too wide a one + shadows image content with a stale volume copy, and a target with no chown entry comes up + root-owned. All three are silent. + +The rationale for the first three is in meta/devcontainer-base/README.md, "Consuming the +image"; for the mounts — which are this repo's, not the base image's — it is in +.claude/CLAUDE.md, "Devcontainer cache volumes". The parsing helpers are pure so they can be exercised directly, same split as the shell tests in this directory. """ @@ -41,6 +47,13 @@ _PLUMBING_COMMAND = "/usr/local/bin/devcontainer-plumbing" _BASE_REPOSITORY = "ghcr.io/syndic/unnatural_designs-devcontainer-base" +# The cache roots the volumes persist. `$GOPATH/pkg` is derivable from nothing in this repo — +# `features/go` bakes GOPATH=/go into the image ENV — so this constant is the pin. None of its +# neighbours belongs here: `~/go` is not GOPATH and nothing writes it; `/go` holds image-built +# tools in `bin/` that a volume would shadow; `/go/pkg/mod` omits the sibling `sumdb` cache. +_GOPATH_PKG = "/go/pkg" +_XDG_CACHE_HOME = "~/.cache" + _LOCAL_ENV_RE = re.compile( r"\A\$\{localEnv:(?P[A-Za-z_][A-Za-z0-9_]*)(?::(?P.*))?\}\Z" ) @@ -116,6 +129,38 @@ def parse_local_env(value: str) -> tuple[str, str | None]: return match.group("var"), match.group("default") +def parse_mount(spec: str) -> dict[str, str]: + """Split one `source=…,target=…,type=…` mount string into its fields. + + Valueless flags (`readonly`) are real mount syntax and come back mapped to `""`. + """ + fields = {} + for part in spec.split(","): + key, _, value = part.partition("=") + fields[key.strip()] = value.strip() + return fields + + +def chown_targets(text: str) -> list[str]: + """The paths post-create.sh makes writable, read off its `for … in ; do` header. + + Reads the loop list rather than the `chown` line so a future edit cannot make the two + disagree. Paths come back as written, `$HOME` included — see `expand_home`. + """ + match = re.search(r"^for volume_target in (?P.+?); do$", text, re.MULTILINE) + if not match: + raise ValueError("post-create.sh has no `for volume_target in …; do` loop") + return [path.strip().strip('"') for path in match.group("paths").split()] + + +def expand_home(path: str, home: str) -> str: + """Resolve `~` / `$HOME` against the *container's* home, never this runner's.""" + for prefix in ("~", "$HOME", "${HOME}"): + if path == prefix or path.startswith(prefix + "/"): + return home + path[len(prefix) :] + return path + + def dockerfile_instructions(text: str) -> list[tuple[str, str]]: """Ordered instruction *heads* — `(FIRST_WORD, rest)` per non-comment, non-blank line. @@ -284,6 +329,66 @@ def test_the_last_stage_is_the_overridable_one(self): self.assertEqual(image, "${BASE_IMAGE}") +class TestMountParsing(unittest.TestCase): + def test_fields_are_split(self): + self.assertEqual( + parse_mount("source=ud-cache,target=/home/vscode/.cache,type=volume"), + {"source": "ud-cache", "target": "/home/vscode/.cache", "type": "volume"}, + ) + + def test_valueless_flag_is_kept(self): + # `readonly` is how the allowed_signers bind spells itself; rejecting it would make + # the volume filter below throw on a perfectly valid mount list. + self.assertEqual(parse_mount("type=bind,readonly")["readonly"], "") + + def test_chown_targets_are_read_off_the_loop(self): + self.assertEqual( + chown_targets('prelude\nfor volume_target in "$HOME/.cache" /go; do\nbody\n'), + ["$HOME/.cache", "/go"], + ) + + def test_chown_targets_requires_the_loop(self): + with self.assertRaises(ValueError): + chown_targets("sudo chown -R vscode /home/vscode/.cache\n") + + def test_home_expansion_uses_the_given_home(self): + self.assertEqual(expand_home("$HOME/.cache", "/home/vscode"), "/home/vscode/.cache") + self.assertEqual(expand_home("~/.cache", "/home/vscode"), "/home/vscode/.cache") + self.assertEqual(expand_home("/go", "/home/vscode"), "/go") + + def test_home_expansion_is_path_segment_wise(self): + # `~foo` is another user's home to a shell, not a subdirectory of ours. + self.assertEqual(expand_home("~foo/.cache", "/home/vscode"), "~foo/.cache") + + +class TestCacheVolumes(unittest.TestCase): + """The volumes that carry derived state across a container rebuild.""" + + @classmethod + def setUpClass(cls): + cls.config = json.loads(strip_jsonc(_DEVCONTAINER_JSON.read_text(encoding="utf-8"))) + cls.home = f"/home/{cls.config['remoteUser']}" + cls.targets = [ + parse_mount(spec)["target"] + for spec in cls.config["mounts"] + if parse_mount(spec).get("type") == "volume" + ] + + def test_the_volume_targets_are_the_two_cache_roots(self): + # Exactly the roots, and the failure is silent in both directions. Too narrow — + # `~/.cache/bazel`, which this replaces — persists one tool and leaves every sibling + # (go-build, bazelisk, uv, pre-commit) ephemeral. Too wide — `/go` rather than the + # `pkg/` inside it — shadows image content with a stale volume copy. + self.assertCountEqual(self.targets, [expand_home(_XDG_CACHE_HOME, self.home), _GOPATH_PKG]) + + def test_post_create_chowns_exactly_the_volume_targets(self): + # A volume whose target the image has no directory for arrives root-owned, so a mount + # added without a chown entry is simply unwritable by remoteUser. + script = (_HERE / "post-create.sh").read_text(encoding="utf-8") + chowned = [expand_home(path, self.home) for path in chown_targets(script)] + self.assertCountEqual(chowned, self.targets) + + class TestHooksCallTheInstalledCommand(unittest.TestCase): def test_hooks_use_the_image_path(self): for hook in _HOOKS: diff --git a/README.md b/README.md index 3726d27..216a897 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ Container_ from the Command Palette. First build takes a few minutes; subsequent [pre-commit](https://pre-commit.com), and [`golangci-lint`](https://golangci-lint.run). All Python tools (`ruff`, `ty`, `pre-commit`) are installed via `uv tool install` at image build time, so the devcontainer has a single Python package manager (uv) and no `pip install --user` in post-create. -Named volumes (`ud-bazel-cache`, `ud-go-cache`) preserve the Bazel and Go caches across container -rebuilds. +Named volumes preserve the two cache roots across container rebuilds: `ud-cache` (`~/.cache` — +Bazel, bazelisk, `go build`, uv, pre-commit) and `ud-go-pkg-cache` (`$GOPATH/pkg` — the Go +module and checksum-db caches, i.e. `/go/pkg`). **Base image**: all of that is layered on top of [`meta/devcontainer-base/`](meta/devcontainer-base/README.md)'s published image, which this repo