diff --git a/bin/box b/bin/box index c6359f1..6a1daf5 100755 --- a/bin/box +++ b/bin/box @@ -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 } diff --git a/drill/drill.sh b/drill/drill.sh index 4b227c5..03413fa 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -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)" + # 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 diff --git a/host/migrate-host.sh b/host/migrate-host.sh index c4b2968..688e920 100644 --- a/host/migrate-host.sh +++ b/host/migrate-host.sh @@ -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 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 diff --git a/templates/grok/user-data.yaml b/templates/grok/user-data.yaml index 535e5d0..1abbdec 100644 --- a/templates/grok/user-data.yaml +++ b/templates/grok/user-data.yaml @@ -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 -- 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