fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH

The first drill run where every failure was the RELEASE CODE, not the
environment. 71 passed, 5 failed; all five traced to four bugs:

1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS
   user.box=1 but never removed user.claudebox=1, and legacy_boxes()
   counted the old tag — so retire saw its own freshly-migrated box as
   un-migrated and refused forever ('legacy boxes still exist:
   legacybox'), leaving claudenet + claude-dev behind. Now: a verified
   re-home drops the legacy tag LAST (after the move is proven, so a
   failure anywhere above still leaves the box valid under one tag or
   the other), and legacy_boxes() ignores boxes already carrying
   user.box=1.

2. box expose died with a bare 'could not add the proxy device' — it
   swallowed incus's reason, exactly the sin this repo keeps punishing.
   Now it prints incus's error. And the mechanism is corrected: a VM's
   proxy needs NAT mode, which requires a static NIC address, so expose
   pins the box's current lease first (which also fixes the restart
   caveat — the exposure no longer points at a lease the box may lose).

3. wait_box's 2-minute window was too short: the legacy box was declared
   dead and then every migration check against it passed. 4 minutes.

4. The grok template hunted for a regular file named exactly
   'grok-build' under /home/grok and found nothing — an installer's drop
   may be a SYMLINK, and its binary name is upstream's to choose. Now it
   tries the plausible names and paths, falls back to any executable
   grok*, links both names, and SAYS what it found — or dumps what the
   installer actually left when it finds nothing. The drill likewise
   dumps the on-disk evidence and the cloud-init log on a --version
   failure instead of discarding the box.
This commit is contained in:
claude-hdb 2026-07-14 19:56:17 +00:00
parent c31fbc63f7
commit 6f7c3bfd60
4 changed files with 82 additions and 10 deletions

27
bin/box
View file

@ -1005,14 +1005,37 @@ cmd_expose() {
incus network acl rule add box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true
# A VM's proxy device needs NAT mode — the plain userspace forkproxy is a
# container thing. NAT mode DNATs host:port → instance:port in netfilter, and
# it REQUIRES the instance's NIC to hold a static address, so pin the lease
# the box already has. Pinning also fixes the restart caveat: the exposure
# survives a reboot instead of pointing at a lease the box no longer owns.
local err; err="$(mktemp)"
if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
# Already overridden (a second exposure on the same box) is fine.
grep -qi 'already' "$err" || {
echo "box: could not pin the box's address (needed for a VM proxy):" >&2
sed 's/^/ /' "$err" >&2; rm -f "$err"
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "expose failed"
}
fi
if incus config device add "$inst" "$dev" proxy \
"listen=tcp:127.0.0.1:$hport" "connect=tcp:$ip:$port" bind=host >/dev/null; then
"listen=tcp:127.0.0.1:$hport" "connect=tcp:$ip:$port" \
bind=host nat=true >/dev/null 2>"$err"; then
rm -f "$err"
echo "box: 127.0.0.1:$hport → $box:$port"
echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)"
else
# NEVER swallow incus's reason — the first cut of this verb died with a bare
# "could not add the proxy device" and told the drill nothing.
echo "box: incus refused the proxy device:" >&2
sed 's/^/ /' "$err" >&2; rm -f "$err"
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "could not add the proxy device"
die "expose failed"
fi
}

View file

@ -57,9 +57,11 @@ inf() { printf ' %s\n' "$*"; }
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
aud() { audit+=("$*"); } # an answer for the #15 audit
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
# 2 min was too short: run 17's legacy box came up AFTER the window closed —
# the drill called it dead and then every migration check on it passed.
local b="$1" _i
for _i in $(seq 1 60); do
for _i in $(seq 1 120); do
box exec "$b" -- true >/dev/null 2>&1 && return 0
sleep 2
done
@ -465,6 +467,16 @@ for t in codex grok; do
else
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
# Do not throw the evidence away — say WHAT the installer actually left.
# Do NOT throw the evidence away — say what the installer actually left
# behind, and what its own log said. Guessing at an upstream installer's
# layout is how this FAILed in the first place.
inf "anything named '$t' on disk:"
in_box "$t" sh -c "find /home /opt /usr/local /usr/bin -maxdepth 4 \\( -type f -o -type l \\) -iname '*$t*' 2>/dev/null | head -8" \
| sed 's/^/ /'
inf "what its cloud-init said:"
in_box "$t" sh -c "grep -iE '$t|install' /var/log/cloud-init-output.log 2>/dev/null | tail -8" \
| sed 's/^/ /'
fi
box rm "$t" --force >/dev/null 2>&1 && ok "$t box removed" || no "$t: could not remove"
else

View file

@ -47,7 +47,17 @@ require_new_stack() {
incus profile show box-net >/dev/null 2>&1 || die "box-net profile does not exist — run host/setup-host.sh first"
}
legacy_boxes() { incus list "user.claudebox=1" -f csv -c n 2>/dev/null; }
# A box is LEGACY only if it still carries the old tag and has NOT been
# re-homed. Counting every user.claudebox=1 box was a bug: re-homing ADDS
# user.box=1 without removing the old tag, so --retire-legacy saw its own
# migrated boxes as un-migrated and refused forever.
legacy_boxes() {
local b
for b in $(incus list "user.claudebox=1" -f csv -c n 2>/dev/null); do
[ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ] && continue # already re-homed
echo "$b"
done
}
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
# tool minted), so the new metadata is the claude template's.
@ -86,6 +96,12 @@ rehome_one() {
done
[ -n "$ip" ] || { warn "$b: never got a 10.88 address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
if incus exec "$b" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
# LAST, and only once the move is VERIFIED: drop the legacy tag. Until this
# point the box wears both tags, so a failure anywhere above leaves it a
# valid box under one name or the other — never orphaned. Now it is simply
# a box, and --retire-legacy can see the old stack is empty.
incus config unset "$b" user.claudebox >/dev/null 2>&1 \
|| warn "$b: migrated, but the legacy tag could not be removed — retire-legacy will still see it"
say "$b re-homed: on boxnet ($ip), resolves + reachable, authed state preserved"
return 0
fi

View file

@ -50,10 +50,31 @@ runcmd:
# binary under the invoking user's home, so run it AS grok, not root.
- sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash'
# 'box exec <b> -- grok-build …' runs a NON-interactive shell that reads no
# rc files. Find whatever the installer dropped and symlink it onto the
# system PATH — the same fix the claude template needed. If the installer's
# binary name changes upstream, this glob is the one line to update.
# rc files, so whatever the installer dropped must be symlinked onto the
# system PATH — the same fix the claude template needed (#15).
#
# The FIRST cut of this hunted only for a regular file named exactly
# 'grok-build' under /home/grok, and found nothing: the drill's grok box
# minted fine and then failed '--version'. Two lessons, both applied here:
# an installer's drop may be a SYMLINK (so -type f alone misses it), and its
# binary name is upstream's to choose (so try the plausible names, and any
# 'grok*' executable, before giving up). Then SAY what was found — a silent
# miss is what cost the last run.
- |
bin="$(find /home/grok -maxdepth 4 -type f -name 'grok-build' 2>/dev/null | head -1)"
[ -n "$bin" ] && ln -sf "$bin" /usr/local/bin/grok-build || echo "grok-build binary not found post-install — check x.ai/cli install layout" >&2
bin=""
for cand in grok-build grok; do
for d in /home/grok/.local/bin /home/grok/.grok/bin /home/grok/bin /usr/local/bin /opt; do
[ -x "$d/$cand" ] && { bin="$d/$cand"; break 2; }
done
done
[ -n "$bin" ] || bin="$(find /home/grok /opt -maxdepth 5 \( -type f -o -type l \) -iname 'grok*' -perm -u+x 2>/dev/null | head -1)"
if [ -n "$bin" ]; then
ln -sf "$bin" /usr/local/bin/grok-build
ln -sf "$bin" /usr/local/bin/grok
echo "grok: linked $bin -> /usr/local/bin/{grok-build,grok}"
else
echo "grok: NO BINARY FOUND after x.ai/cli/install.sh — the box is up but the CLI is not on PATH." >&2
echo "grok: what the installer left under /home/grok:" >&2
find /home/grok -maxdepth 4 \( -type f -o -type l \) -perm -u+x 2>/dev/null | head -20 >&2
fi
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/grok/.bashrc