Skip to content

Commit 6d54358

Browse files
endolinbotclaude
andcommitted
garden: derive instance identity from checkout location, mirror home, per-user image
The launcher keyed the container name off the mutable .garden shard file while the bind mount used a fixed /home/<user> path. The decoupling let a container strand under a name matching no checkout (the "lost container"), and the host<->container path mismatch (-v $SCRIPT_DIR:/home/kris) made git canonicalize journal-worktree paths to a bind-mount source that did not exist inside the container, corrupting the journal worktree on every op. Identity is now derived purely from the checkout's canonical path (<hostname>-<basename>-<hash8>), pinned into --name/--hostname at creation, so it is collision-free, stable, and self-healing; the .garden file and GARDEN env knob are gone (common.sh resolves via its hostname -s fallback). The checkout is bind-mounted at its own host path and the bot user's home is relocated to match (entrypoint usermod -d, launcher GARDEN_HOME), so an absolute path denotes the same file inside the container and out -- which also fixes the journal-worktree corruption. The container's unix user (name + uid) is baked per-build to match the host user, tagging the image per-user (garden-<user>); nothing is pinned to one account. Adds a `create` subcommand for headless bring-up. Verified: two concurrent instances (endolin-garden, endolin-garden2) with mirrored homes, correct GARDEN/GARDEN_ROOT, idempotent re-attach, and a clean git worktree add whose gitdir target exists in-container. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5df0a67 commit 6d54358

5 files changed

Lines changed: 216 additions & 150 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Create `skills/<name>/SKILL.md`. Sections: purpose, inputs, state (if any), proc
7979

8080
## Host environment
8181

82-
The garden lives in the bot user's home directory; that directory is what `<garden-root>` refers to throughout this document. Each host has a logical **`GARDEN` identity** — the shard name that keys job claims, per-host worker counts, journal index entries, and the leader marker, and **must be unique across running instances**. Its resolution order (`GARDEN` env → the gitignored `<garden-root>/.garden` file → `hostname -s`), the `.garden` naming knob, why an exported env var does not reach the `--user` units, and the container-rename move all live in [context/first-run/identity.md](context/first-run/identity.md).
82+
The garden lives in the bot user's home directory; that directory is what `<garden-root>` refers to throughout this document, and the container **mirrors** it — the checkout is bind-mounted at its own host path and the bot user's home is relocated to match, so an absolute path denotes the same file inside the container and out. Each instance has a logical **`GARDEN` identity** — the shard name that keys job claims, per-host worker counts, journal index entries, and the leader marker, and **must be unique across running instances**. That identity is **derived from the checkout's location**, `<hostname>-<basename>-<hash8>` of the canonical path, and pinned into the container's `--name`/`--hostname` at creation; the fleet reads it back through `common.sh`'s `hostname -s` fallback. There is no `.garden` file or `GARDEN` env knob to set — distinct directories yield distinct instances automatically, and the only cross-instance requirement the human must ensure is that **hosts have unique short hostnames**. The derivation, the multi-instance recipe, and the expert overrides live in [context/first-run/identity.md](context/first-run/identity.md). The container's unix user matches the **host** user (name + uid, baked per-build), so nothing is pinned to one maintainer's account.
8383

8484
Each host configures its bot identity once in the garden repo's local git config:
8585

Dockerfile

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
# recipe (privileged, writable cgroup mount, tmpfs at /run and /tmp,
1010
# STOPSIGNAL SIGRTMIN+3 — supplied by the `garden` launcher script).
1111
#
12-
# Identity: runs as user `kris` (uid 1000) and uses whatever ssh / gh
13-
# credentials are present in the bind-mounted /home/kris. The bot's git
14-
# identity (e.g. endolinbot / kriscendobot) is the repo-local git config,
15-
# not a separate unix user. The per-host logical name must be UNIQUE
16-
# across garden instances (see CLAUDE.md § Job system) and is fixed at
17-
# container creation via the launcher's --hostname.
12+
# Identity: runs as a unix user matching the HOST user (name + uid, via
13+
# --build-arg USERNAME/USER_UID) and uses whatever ssh / gh credentials are
14+
# present in the bind-mounted home. The container home is relocated at runtime
15+
# to mirror the checkout's host path (entrypoint `usermod -d`, launcher
16+
# GARDEN_HOME). The bot's git identity (a bot login) is the repo-local git config,
17+
# not a separate unix user. The per-host logical name is
18+
# the location-derived instance id (<hostname>-<basename>-<hash>), unique across
19+
# instances and fixed at container creation via the launcher's --hostname.
1820

1921
FROM ubuntu:24.04
2022

@@ -23,6 +25,17 @@ ARG GO_VERSION=1.23.6
2325
ARG DOTFILES_REPO=https://github.com/kriskowal/dotfiles.git
2426
ARG VUNDLE_REPO=https://github.com/VundleVim/Vundle.vim.git
2527

28+
# The unix user is baked to match the HOST user running ./garden (name + uid),
29+
# passed by the launcher's cmd_build. Nothing is hardcoded to one maintainer's
30+
# account, and the launcher tags the image per-user (garden-<user>) so builds
31+
# don't drift or collide across users. (USERNAME is first USED at the useradd
32+
# step far below, so declaring it here does not invalidate the expensive apt /
33+
# node / go layers; GARDEN_USER is exported as an ENV late, for the same reason.)
34+
# Neutral defaults (never the maintainer's account); the launcher always passes
35+
# the real host user via --build-arg, so these apply only to a bare `docker build`.
36+
ARG USERNAME=bot
37+
ARG USER_UID=1000
38+
2639
ENV DEBIAN_FRONTEND=noninteractive
2740

2841
# systemd + dbus + base packages.
@@ -114,21 +127,23 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
114127
RUN npm install -g @anthropic-ai/claude-code \
115128
&& command -v claude
116129

117-
# Create kris user with uid 1000 to match the host user so the
118-
# bind-mounted home stays writable.
130+
# Create the bot user matching the HOST user (name + uid, from --build-arg) so the
131+
# bind-mounted home stays writable and nothing is pinned to one account. Ubuntu
132+
# 24.04 ships a default `ubuntu` user at uid 1000; remove it first so USER_UID
133+
# (often 1000) is free.
119134
RUN userdel -r ubuntu 2>/dev/null || true \
120-
&& useradd -m -s /bin/bash -u 1000 -G sudo kris \
121-
&& echo 'kris ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
135+
&& useradd -m -s /bin/bash -u "${USER_UID}" -G sudo "${USERNAME}" \
136+
&& echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
122137

123138
# Dotfiles in /opt so the bind mount can't mask them.
124139
RUN git clone "${DOTFILES_REPO}" /opt/dotfiles \
125-
&& chown -R kris:kris /opt/dotfiles
140+
&& chown -R "${USERNAME}:${USERNAME}" /opt/dotfiles
126141

127142
# Vundle + plugins.
128-
USER kris
143+
USER ${USERNAME}
129144
RUN git clone "${VUNDLE_REPO}" /opt/dotfiles/vim/bundle/Vundle.vim \
130-
&& ln -sfn /opt/dotfiles/vim /home/kris/.vim \
131-
&& ln -sfn /opt/dotfiles/.vimrc /home/kris/.vimrc \
145+
&& ln -sfn /opt/dotfiles/vim "/home/${USERNAME}/.vim" \
146+
&& ln -sfn /opt/dotfiles/.vimrc "/home/${USERNAME}/.vimrc" \
132147
&& vim -E -u /opt/dotfiles/.vimrc -i NONE \
133148
-c 'set nomore' \
134149
-c 'PluginInstall' \
@@ -145,7 +160,7 @@ RUN printf '%s\n' \
145160
'export PATH="$HOME/bin:$HOME/go/bin:/opt/go-tools/bin:/usr/local/go/bin:$PATH"' \
146161
> /etc/profile.d/garden.sh
147162

148-
ENV PATH="/home/kris/bin:/home/kris/go/bin:${PATH}"
163+
ENV PATH="/home/${USERNAME}/bin:/home/${USERNAME}/go/bin:${PATH}"
149164

150165
# Mask systemd units that don't make sense in a container (they would
151166
# otherwise fail noisily on boot).
@@ -160,12 +175,18 @@ RUN systemctl mask \
160175
getty-static.service \
161176
|| true
162177

163-
# Enable lingering for kris so systemd starts user@1000.service at boot,
164-
# which brings up the user-mode garden-* units (installed by
178+
# Enable lingering for the bot user so systemd starts its user@<uid>.service at
179+
# boot, which brings up the user-mode garden-* units (installed by
165180
# scripts/jobs/install-units.sh into ~/.config/systemd/user/) without a
166181
# logged-in session. This is the headless prerequisite the CLAUDE.md
167182
# startup procedure relies on.
168-
RUN mkdir -p /var/lib/systemd/linger && touch /var/lib/systemd/linger/kris
183+
RUN mkdir -p /var/lib/systemd/linger && touch "/var/lib/systemd/linger/${USERNAME}"
184+
185+
# Bake the bot user's name so the runtime entrypoint (which relocates this user's
186+
# home to the mirrored checkout path) knows it without a hardcoded account.
187+
# Declared LATE, after the expensive apt/node/go layers, so it never invalidates
188+
# their cache.
189+
ENV GARDEN_USER=${USERNAME}
169190

170191
# Entrypoint links dotfiles and prepares the user-systemd dir before
171192
# exec'ing systemd as PID 1.
@@ -175,11 +196,11 @@ RUN chmod +x /usr/local/bin/garden-entrypoint
175196
# systemd's clean-shutdown signal.
176197
STOPSIGNAL SIGRTMIN+3
177198

178-
# PID 1 is systemd (entrypoint runs as root, its domain); the garden
179-
# units run as user kris via the user@1000 manager. Interactive access is
180-
# `docker exec -it -u kris ... bash -l` (the `garden` launcher does this) —
181-
# entering as kris, not root, so the session uses kris's gh/ssh credentials
199+
# PID 1 is systemd (entrypoint runs as root, its domain); the garden units run
200+
# as the bot user via its user@<uid> manager. Interactive access is
201+
# `docker exec -it -u <hostuser> ... bash -l` (the `garden` launcher does this) —
202+
# entering as the bot user, not root, so the session uses its gh/ssh credentials
182203
# and drives `systemctl --user` directly without sudo.
183-
WORKDIR /home/kris
204+
WORKDIR /home/${USERNAME}
184205
ENTRYPOINT ["/usr/local/bin/garden-entrypoint"]
185206
CMD ["/lib/systemd/systemd"]

context/first-run/identity.md

Lines changed: 53 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,73 +4,82 @@ Every garden instance has one logical name — its **`GARDEN` shard identity**
44
and it **must be unique across every running instance you own**. It keys job
55
claims, per-host worker counts (`hosts/<host>`), journal index entries, and the
66
leader marker; two instances sharing a name silently corrupt each other's
7-
per-host state. This page is the naming knob and the uniqueness discipline: the
8-
`.garden` file as the one place the name lives, `GARDEN=… ./garden` as sugar
9-
over it, why an exported env var does not reach the fleet, and the rename /
10-
parallel-pool moves. If your question is "how do I name this instance" or "these
11-
two instances are stepping on each other," you are in the right place; the
12-
multi-host leader/follower mechanics that the marker gates are
7+
per-host state. The good news: you no longer set this name by hand. It is
8+
**derived from where the checkout lives**, so it is unique by construction and
9+
cannot drift. This page explains that derivation and how to run several
10+
instances; the multi-host leader/follower mechanics that the marker gates are
1311
`../operations/leader-follower.md`.
1412

15-
## The one naming knob: `.garden`
13+
## Identity is the checkout's location
1614

17-
The streamlined, preferred way to name an instance is one line **before the
18-
first `./garden`**:
15+
The launcher computes the identity at container creation from the checkout's
16+
canonical path:
1917

20-
```sh
21-
echo petunias > .garden
2218
```
19+
<hostname>-<basename>-<hash8> e.g. endolin-garden2-5bcdff64
20+
```
21+
22+
- **`hostname`** — the host's short hostname, so instances on different hosts
23+
never collide in the shared journal.
24+
- **`basename`** — the checkout directory's own name, the human-readable middle.
25+
- **`hash8`** — the first 8 hex of the SHA-256 of the full canonical path, which
26+
disambiguates same-named directories at different paths on one host (e.g.
27+
`/srv/a/garden` vs `/srv/b/garden`).
28+
29+
There is **no `.garden` file and no environment knob** to seed, edit, or forget.
30+
Because the id is a pure function of location and is pinned into the container's
31+
`--name` and `--hostname` at creation, it can never drift away from the container
32+
afterward — the failure that used to strand a container under a name matching no
33+
checkout is now structurally impossible.
2334

24-
The launcher reads `.garden` when present and derives the container name and
25-
`--hostname` from it. Bare `./garden` needs **no environment variables** — every
26-
default is satisfactory, and an unnamed instance falls back to the container
27-
hostname. You only name an instance when you run **more than one**.
35+
## Running more than one instance
2836

29-
The convenience form does the same thing through an env var:
37+
Put each instance in **its own directory**. Distinct paths yield distinct ids,
38+
distinct containers, and distinct (mirrored) homes automatically:
3039

3140
```sh
32-
GARDEN=petunias ./garden
41+
# two collaborating instances on one host — nothing to name:
42+
( cd ~/garden && ./garden create ) # -> <host>-garden-<hashA>
43+
( cd ~/garden2 && ./garden create ) # -> <host>-garden2-<hashB>
3344
```
3445

35-
This sets the container hostname **and** writes `.garden`, so the container is
36-
built and named from that identity either way. The env var is **sugar over the
37-
file**, and the file is the documented default. `GARDEN_CONTAINER` /
38-
`GARDEN_HOSTNAME` remain as expert overrides; `GARDEN_SHARD` is a deprecated
39-
alias for `GARDEN` for one release.
46+
The container **home is mirrored** to the checkout's own host path (e.g. a
47+
checkout at `/srv/garden2` has `$HOME` = `/srv/garden2` inside the container), so
48+
an absolute path means the same thing inside the container and out, and each
49+
instance's credentials live in its own `.ssh` / `.config/gh` under that path.
4050

4151
## How the fleet resolves the identity
4252

4353
Every fleet script resolves `GARDEN` as: **`GARDEN` env → the gitignored
44-
`.garden` file → `hostname -s`** (`common.sh`). The durable file exists because
45-
an **exported `GARDEN` does not reach the systemd `--user` units**they are
46-
started by the user manager, not your shell, so they read the file, not your
47-
environment. That is the whole reason the name is written to disk rather than
48-
left in the environment: set it at container-creation time so it lands in
49-
`.garden`, and every unit sees it.
54+
`.garden` file → `hostname -s`** (`common.sh`). With the location-derived scheme
55+
there is no `.garden` file, so resolution lands on **`hostname -s`**and the
56+
launcher has pinned the container's `--hostname` to the computed instance id, so
57+
every systemd `--user` unit sees exactly that id without any environment
58+
plumbing. (An exported `GARDEN` still does not reach the `--user` manager; that
59+
is why the id rides `--hostname` instead.)
5060

5161
## The uniqueness check (the human's one answer)
5262

53-
The kernel hostname cannot be changed from inside a container (its capabilities
54-
are zero), so the logical name is **fixed at container creation** via
55-
`--hostname`/`--name` (both `GARDEN_CONTAINER`). During the tutorial the liaison
56-
reads `.garden` and asks the single question only the human can answer: **"is
57-
this name unique among your running garden instances?"** A default or a
58-
collision is the only thing that needs fixing here.
63+
Within a host, uniqueness is automatic (distinct paths → distinct ids). The one
64+
thing only you can guarantee is that your **hosts have distinct short
65+
hostnames** — the cross-host tiebreaker in the shared journal. If two hosts share
66+
a hostname, give one a distinct hostname (or use `GARDEN_HOSTNAME`, below) before
67+
standing up the second.
5968

60-
## Rename, and the parallel-pool move
69+
## Rename, and expert overrides
6170

62-
To **rename** an existing instance, reset and re-create it under the new name:
71+
To **rename** an instance, move (or re-clone) the checkout to a new path and
72+
re-create the container; the id follows the new location:
6373

6474
```sh
65-
./garden reset
66-
echo <unique-name> > .garden # or: GARDEN=<unique-name> ./garden
67-
./garden
75+
./garden reset # remove the old container
76+
# move the checkout to its new path, then:
77+
./garden # re-create — new path yields the new id
6878
```
6979

70-
For a **lighter, per-invocation** identity — a second follower pool on the same
71-
machine, launched from a checked-out worktree with no Dockerfile change — just
72-
export `GARDEN=<unique>` for that invocation. Set `GARDEN` at creation only when
73-
a pool's identity must differ from its hostname.
80+
`GARDEN_CONTAINER` and `GARDEN_HOSTNAME` remain expert overrides for the rare
81+
case where the container name or logical id must differ from the location-derived
82+
default (e.g. two hosts that unavoidably share a short hostname).
7483

75-
Rationale for the container/hostname coupling and the resolution order lives in
84+
Rationale for the location-derived scheme and the home mirroring lives in
7685
`CLAUDE.md` § Host environment; this page is the operator's how-to.

entrypoint.sh

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
#
44
# Runs as root inside the garden container. Before handing PID 1 to
55
# systemd it:
6-
# 1. Symlinks dotfiles from /opt/dotfiles into /home/kris.
7-
# 2. Prepares /home/kris/.config/systemd/user/ owned by kris, so
6+
# 1. Relocates the bot user's home to the mirrored checkout path (GARDEN_HOME).
7+
# 2. Symlinks dotfiles from /opt/dotfiles into that home.
8+
# 3. Prepares <home>/.config/systemd/user/ owned by the bot user, so
89
# `systemctl --user enable` (run later by scripts/jobs/install-units.sh)
910
# can write its default.target.wants/ links and the units survive a
1011
# container restart.
11-
# 3. exec's "$@" (the CMD, /lib/systemd/systemd).
12+
# 4. exec's "$@" (the CMD, /lib/systemd/systemd).
13+
#
14+
# The bot user's name is baked into the image (ENV GARDEN_USER, = the host user)
15+
# and read here so nothing is pinned to one account.
1216
#
1317
# Note: the garden units carry an @GARDEN_ROOT@ placeholder and must be
1418
# RENDERED into ~/.config/systemd/user/ by scripts/jobs/install-units.sh
@@ -19,7 +23,25 @@
1923
set -e
2024

2125
DOTFILES=/opt/dotfiles
22-
HOME_DIR=/home/kris
26+
# The bot user, baked to match the host user (Dockerfile ARG USERNAME → ENV
27+
# GARDEN_USER). If the env is somehow absent, fall back to the first real
28+
# (uid >= 1000) account in the image rather than a guessed literal, so we never
29+
# target a nonexistent user and abort PID-1 boot.
30+
GARDEN_USER="${GARDEN_USER:-$(getent passwd | awk -F: '$3>=1000 && $3<65534 {print $1; exit}')}"
31+
32+
# Home is MIRRORED to the checkout's host path when the launcher passes
33+
# GARDEN_HOME (a bind mount lives at that same path). Relocate the bot user's home
34+
# in /etc/passwd to it — no -m, since the bind mount already provides the directory
35+
# — so $HOME, the login shell, and the systemd --user manager all agree on the
36+
# mirrored path. Absolute paths then mean the same thing inside the container and
37+
# on the host. Done here, as root, before systemd (PID 1) starts and while the bot
38+
# user still has no running process. Guarded on the user existing so a misresolved
39+
# name can never crash boot. Absent GARDEN_HOME, home stays as built.
40+
HOME_DIR="${GARDEN_HOME:-$(getent passwd "$GARDEN_USER" | cut -d: -f6)}"
41+
if [ -n "${GARDEN_HOME:-}" ] && getent passwd "$GARDEN_USER" >/dev/null \
42+
&& [ "$(getent passwd "$GARDEN_USER" | cut -d: -f6)" != "$HOME_DIR" ]; then
43+
usermod -d "$HOME_DIR" "$GARDEN_USER"
44+
fi
2345
SYSTEMD_USER_DIR="${HOME_DIR}/.config/systemd/user"
2446

2547
link_if_safe() {
@@ -40,15 +62,15 @@ if [[ -d "$DOTFILES" ]]; then
4062
link_if_safe "$DOTFILES/zshrc" "$HOME_DIR/.zshrc"
4163
mkdir -p "$HOME_DIR/.config/git"
4264
link_if_safe "$DOTFILES/git/.gitconfig" "$HOME_DIR/.config/git/config"
43-
chown -h kris:kris \
65+
chown -h "$GARDEN_USER:$GARDEN_USER" \
4466
"$HOME_DIR/.bashrc" "$HOME_DIR/.bash_profile" "$HOME_DIR/.vimrc" \
4567
"$HOME_DIR/.vimrc.local" "$HOME_DIR/.vim" "$HOME_DIR/.tmux.conf" \
4668
"$HOME_DIR/.tigrc" "$HOME_DIR/.zshrc" "$HOME_DIR/.config/git/config" \
4769
2>/dev/null || true
4870
fi
4971

50-
# Prepare the user-systemd tree (must be kris-owned for `--user enable`).
72+
# Prepare the user-systemd tree (must be owned by the bot user for `--user enable`).
5173
mkdir -p "$SYSTEMD_USER_DIR"
52-
chown -R kris:kris "$HOME_DIR/.config/systemd" 2>/dev/null || true
74+
chown -R "$GARDEN_USER:$GARDEN_USER" "$HOME_DIR/.config/systemd" 2>/dev/null || true
5375

5476
exec "$@"

0 commit comments

Comments
 (0)