Add runtime post-recreate sanity check (peer of smoke-test.sh)

scripts/recreate-sanity-check.sh verifies what is actually live in a
recreated container — persisted volumes, pi runtime wiring (keybindings,
extensions, mempalace.ts bridge, settings.json, fork/obsmem/studio
registrations), /tmp/sshcm, skel defaults, /opt toolkits. smoke-test.sh
runs at build time with --entrypoint="" and cannot see any of this.

Variant (studio/plain) auto-detected via /opt/pi-studio. pi version is
asserted only with --expected-version (built from 'latest', no Dockerfile
pin to self-derive). Maintainer tooling, not baked into the image.

Documented in README and CHANGELOG.
This commit is contained in:
pi
2026-06-15 22:04:02 +02:00
parent f8da7890df
commit 4ed6764323
3 changed files with 312 additions and 0 deletions
+19
View File
@@ -37,6 +37,25 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
— which hashes differently than a SHA and triggers one *extra* rebuild, — which hashes differently than a SHA and triggers one *extra* rebuild,
never a *missed* one (fail-toward-rebuild). never a *missed* one (fail-toward-rebuild).
### Added (maintainer tooling, no image change)
- **`scripts/recreate-sanity-check.sh`** — runtime post-recreate sanity
check; the runtime peer of `smoke-test.sh`. Where `smoke-test.sh` runs at
build time with `--entrypoint=""` (and so can never see persisted volumes
or the entrypoint's runtime deploy), this verifies what is actually live
in the container *after* `docker compose up -d --force-recreate`:
persisted named volumes survived, the pi runtime wiring is intact
(keybindings symlink, ≥4 extensions, `mempalace.ts` bridge, `settings.json`,
and pi-fork / pi-observational-memory / pi-studio registrations),
`/tmp/sshcm` is mode 700, shell defaults re-seeded, and `/opt` toolkits
intact. Variant (studio/plain) auto-detected via `/opt/pi-studio`. Since
pi is built from `latest` (no concrete Dockerfile pin), the version check
asserts only when `--expected-version` is passed, else WARNs. Not baked
into the image — repo/maintainer tooling, same category as
`smoke-test.sh`. A short-name wrapper (`pi-devbox-sanity`) lives in
`cli_utils/bin`, kept separate from opencode-devbox's `devbox-sanity` so
hosts with only one devbox checked out stay self-contained.
### Docs (no image change) ### Docs (no image change)
- Correct the MemPalace `diary_write` anyOf workaround watch-target in - Correct the MemPalace `diary_write` anyOf workaround watch-target in
+20
View File
@@ -539,6 +539,26 @@ ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and
./scripts/smoke-test.sh joakimp/pi-devbox:latest ./scripts/smoke-test.sh joakimp/pi-devbox:latest
``` ```
`smoke-test.sh` is a **build-time** check (runs with `--entrypoint=""`), so
it validates image contents and a fresh entrypoint deploy — it never sees a
recreated container's persisted volumes.
### Post-recreate sanity check
After `docker compose up -d --force-recreate`, run the **runtime** peer of
`smoke-test.sh` from *inside* the container to confirm the new image is live,
persisted volumes survived, and pi runtime wiring is intact:
```bash
./scripts/recreate-sanity-check.sh # auto-detects variant
./scripts/recreate-sanity-check.sh --expected-version 0.79.3 # assert pi version
```
If `cli_utils` is on your PATH, the `pi-devbox-sanity` wrapper runs the same
check by short name and locates the repo automatically (override with
`PI_DEVBOX_REPO=/path/to/pi-devbox`). Like `smoke-test.sh`, this script is
maintainer tooling and is **not** shipped in the published image.
## Versioning and release ## Versioning and release
pi-devbox follows semver-ish: pi-devbox follows semver-ish:
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env bash
# Runtime post-recreate verification for pi-devbox.
#
# Verifies that after `docker compose up -d --force-recreate`:
# - The new image is actually live (pi version matches, when an expected
# version is supplied — see the version note below)
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
# nvim data, uv cache, ssh-local)
# - pi runtime wiring is intact: keybindings symlink, ≥4 extensions, the
# mempalace.ts bridge, settings.json, and the pi-fork /
# pi-observational-memory / (studio variant) pi-studio package registrations
# - Shell defaults re-seeded from /etc/skel-devbox
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
# - /opt toolkits intact
# - Known expected-absences don't regress
#
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
# a recreated container's persisted volumes or the entrypoint's runtime
# deploy. This script is its runtime counterpart: it inspects what is actually
# live in the container you are sitting in after a recreate.
#
# It is NOT baked into the published Docker Hub image; run it from a checkout of
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
# `docker pull` consumer is not the audience and will not have this file.
#
# Version note: pi's version is resolved from `latest` at CI build time and is
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
# So unlike opencode-devbox, this script cannot self-derive an expected version
# from the Dockerfile. Pass --expected-version to assert a match; without it the
# live pi version is reported as an informational WARN, not a failure.
#
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
#
# Exit codes:
# 0 all checks passed
# 1 one or more checks failed
# 2 usage error
set -euo pipefail
EXPECTED_VERSION=""
VARIANT=""
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--expected-version)
EXPECTED_VERSION="$2"
shift 2
;;
--variant)
VARIANT="$2"
shift 2
;;
*)
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
exit 2
;;
esac
done
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
# /opt/pi-studio; the plain variant does not.
if [ -z "$VARIANT" ]; then
if [ -d /opt/pi-studio ]; then
VARIANT="studio"
else
VARIANT="plain"
fi
fi
# Print header with git context
echo "=== Recreate sanity check (variant: $VARIANT) ==="
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
else
echo " Repo HEAD: (not a git repo or no tags)"
fi
echo
echo "-- pi version --"
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
if [ -n "$EXPECTED_VERSION" ]; then
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
pass "pi version $ACTUAL_VERSION"
else
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
fi
else
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
fi
else
fail "pi --version failed"
fi
echo
echo "-- Persisted named volumes (must survive --force-recreate) --"
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
# keybindings symlink. Must exist and be non-empty after recreate.
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
pass "~/.pi/agent exists and is non-empty"
else
fail "~/.pi/agent missing or empty"
fi
# shell history volume (devbox-shell-history). An empty .bash_history right
# after recreate is NORMAL — only the mount point must exist.
if [ -d "$HOME/.cache/bash" ]; then
pass "~/.cache/bash exists as directory"
else
fail "~/.cache/bash missing or not a directory"
fi
# remaining persisted volumes — mount points must exist
for vol_path in \
"$HOME/.local/share/zoxide" \
"$HOME/.local/share/nvim" \
"$HOME/.local/share/uv" \
"$HOME/.ssh-local"; do
if [ -d "$vol_path" ]; then
pass "$vol_path exists"
else
fail "$vol_path missing or not a directory"
fi
done
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
# devbox-palace named volume is commented out; the palace is reached via the
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
# is NOT a recreate regression here.
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
else
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
fi
else
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
fi
echo
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
# keybindings symlink (pi-toolkit)
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
else
fail "~/.pi/agent/keybindings.json missing or not a symlink"
fi
# extensions deployed (pi-extensions) — expect ≥4 *.ts
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
if [ "$EXT_COUNT" -ge 4 ]; then
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
else
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
fi
# mempalace.ts bridge symlink
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
else
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
fi
# settings.json bootstrapped
if [ -f "$HOME/.pi/agent/settings.json" ]; then
pass "~/.pi/agent/settings.json bootstrapped"
else
fail "~/.pi/agent/settings.json missing"
fi
# pi package registrations (pi install <local-path> → recorded in settings.json)
if [ -f "$HOME/.pi/agent/settings.json" ]; then
for pkg in pi-fork pi-observational-memory; do
if grep -q "$pkg" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
pass "$pkg registered in settings.json"
else
fail "$pkg not registered in settings.json"
fi
done
if [ "$VARIANT" = "studio" ]; then
if grep -q "pi-studio" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
pass "pi-studio registered in settings.json"
else
fail "pi-studio not registered in settings.json (studio variant)"
fi
fi
fi
echo
echo "-- ssh ControlMaster dir --"
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
pass "/tmp/sshcm exists with mode 700"
else
fail "/tmp/sshcm missing or not mode 700"
fi
echo
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
if [ -f "$HOME/.bash_aliases" ]; then
pass "~/.bash_aliases exists"
else
fail "~/.bash_aliases missing"
fi
if [ -f "$HOME/.inputrc" ]; then
pass "~/.inputrc exists"
else
fail "~/.inputrc missing"
fi
echo
echo "-- cli_utils bind-mount --"
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
pass "/workspace/cli_utils exists with .git subdir"
else
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
fi
echo
echo "-- Baked /opt toolkits --"
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
if [ -d "$opt_path" ]; then
pass "$opt_path exists"
else
fail "$opt_path missing"
fi
done
if [ "$VARIANT" = "studio" ]; then
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
pass "/opt/pi-studio exists with prebuilt client bundle"
else
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
fi
fi
# mempalace MCP entrypoint on PATH
if command -v mempalace-mcp >/dev/null 2>&1; then
pass "mempalace-mcp on PATH"
else
fail "mempalace-mcp not on PATH"
fi
echo
echo "-- Known expected-absences (regressions vs by-design) --"
if ! command -v go >/dev/null 2>&1; then
warn "go absent — expected unless image built with INSTALL_GO=true"
else
pass "go is on PATH"
fi
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
fi
echo
if [ "$FAILED" -gt 0 ]; then
echo "=== FAILED: $FAILED check(s) ===" >&2
exit 1
fi
echo "=== PASSED ==="