diff --git a/CHANGELOG.md b/CHANGELOG.md index 0daf3c6..79fe44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, 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) - Correct the MemPalace `diary_write` anyOf workaround watch-target in diff --git a/README.md b/README.md index e7b62e5..329f8d1 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,26 @@ ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and ./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 pi-devbox follows semver-ish: diff --git a/scripts/recreate-sanity-check.sh b/scripts/recreate-sanity-check.sh new file mode 100755 index 0000000..12fafd1 --- /dev/null +++ b/scripts/recreate-sanity-check.sh @@ -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 → 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 ==="