Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 7 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 16 additions & 6 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 108 additions & 3 deletions .devcontainer/test_devcontainer_config.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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<var>[A-Za-z_][A-Za-z0-9_]*)(?::(?P<default>.*))?\}\Z"
)
Expand Down Expand Up @@ -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 <paths>; 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<paths>.+?); 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.

Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading