#!/bin/bash # Migration script for kb#219 — move Adolf's runtime state off the named # Docker volume (openai_adolf-state) onto a host bind mount at # /mnt/ssd/dbs/adolf/state, matching the convention every other Agap # service already follows (hindsight, litellm, qdrant, langfuse, ...). # # SAFETY MODEL: # - COPY ONLY. Never touches or deletes the source volume. The volume # stays intact and usable as a rollback source until a human explicitly # removes it (see rollback section in the compose-diff writeup / # kb#219 report), long after this script has run and the container has # been soak-tested on the new mount. # - Dry-run by default. Pass --apply to actually copy. # - Idempotent. Safe to re-run; re-copying onto an already-populated # destination just refreshes it (cp -a overwrite-in-place). It will # NOT delete files at the destination that were removed from the # source between runs -- if that matters, wipe the dest dir yourself # before re-running. # - Verifies file counts + a sha256 manifest diff between source and # destination before declaring success. Non-zero exit if they disagree. # - Uses only `docker run` (alvis is in the `docker` group -- no `sudo` # needed for container operations) to read the volume; never reads # /var/lib/docker/volumes directly (root-only, 0700). # - Does NOT create /mnt/ssd/dbs/adolf itself. That directory tree is # root-owned (/mnt/ssd/dbs is 0755 root:root, same as every other # service dir under it) and must be created + chowned by a human with # sudo first -- see the paste-ready root block in the kb#219 report. # This script aborts early with a clear message if the destination # parent doesn't exist or isn't writable. # # USAGE: # ./migrate-adolf-state.sh # dry run (default), prints plan # ./migrate-adolf-state.sh --apply # actually copies + verifies # ./migrate-adolf-state.sh --apply --dest /path/to/scratch --volume some-test-volume # # point at a throwaway volume/dest for a trial run # # This script is NOT executed against live state as part of kb#219 prep. # It has been dry-run tested and trial-run tested against a throwaway # volume with a handful of files (see kb#219 report for the transcript). set -euo pipefail SRC_VOLUME="openai_adolf-state" DEST_DIR="/mnt/ssd/dbs/adolf/state" APPLY=0 while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift ;; --dest) DEST_DIR="$2"; shift 2 ;; --volume) SRC_VOLUME="$2"; shift 2 ;; -h|--help) grep '^#' "$0" | sed 's/^#//' exit 0 ;; *) echo "Unknown argument: $1" >&2 exit 2 ;; esac done echo "== kb#219 adolf-state migration ==" echo "Source volume : $SRC_VOLUME" echo "Dest dir : $DEST_DIR" echo "Mode : $([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)" echo # --- 0. sanity: source volume exists --- if ! docker volume inspect "$SRC_VOLUME" >/dev/null 2>&1; then echo "ERROR: source volume '$SRC_VOLUME' does not exist." >&2 exit 1 fi # --- 1. sanity: destination parent exists and is writable --- DEST_PARENT="$(dirname "$DEST_DIR")" if [ ! -d "$DEST_PARENT" ]; then cat >&2 <&2 exit 1 fi mkdir -p "$DEST_DIR" # --- 2. source manifest (counts + sha256, computed inside a container) --- echo "-- Computing source manifest (read-only mount of $SRC_VOLUME) --" SRC_COUNT=$(docker run --rm -v "$SRC_VOLUME":/from:ro alpine sh -c "find /from -type f | wc -l") echo "Source file count: $SRC_COUNT" if [ "$APPLY" -eq 0 ]; then echo echo "[DRY RUN] Would copy $SRC_COUNT files from volume '$SRC_VOLUME' into $DEST_DIR," echo "[DRY RUN] then verify file count + sha256 manifest match." echo "[DRY RUN] Re-run with --apply to actually copy." exit 0 fi # --- 3. copy (tar stream preserves ownership/perms across the boundary) --- echo "-- Copying (tar stream, preserves perms/ownership) --" docker run --rm \ -v "$SRC_VOLUME":/from:ro \ -v "$DEST_DIR":/to \ alpine sh -c "cd /from && tar cf - . | (cd /to && tar xf -)" # --- 4. verify: file count --- DEST_COUNT=$(docker run --rm -v "$DEST_DIR":/to:ro alpine sh -c "find /to -type f | wc -l") echo "Dest file count: $DEST_COUNT" if [ "$SRC_COUNT" != "$DEST_COUNT" ]; then echo "ERROR: file count mismatch (source=$SRC_COUNT dest=$DEST_COUNT). NOT declaring success." >&2 exit 1 fi # --- 5. verify: sha256 manifest diff --- echo "-- Verifying sha256 manifests match --" SRC_MANIFEST=$(mktemp) DEST_MANIFEST=$(mktemp) trap 'rm -f "$SRC_MANIFEST" "$DEST_MANIFEST"' EXIT docker run --rm -v "$SRC_VOLUME":/from:ro alpine sh -c \ "cd /from && find . -type f -exec sha256sum {} \; | sort -k2" > "$SRC_MANIFEST" docker run --rm -v "$DEST_DIR":/to:ro alpine sh -c \ "cd /to && find . -type f -exec sha256sum {} \; | sort -k2" > "$DEST_MANIFEST" if diff -u "$SRC_MANIFEST" "$DEST_MANIFEST" > /tmp/adolf-state-migration.diff; then echo "OK: manifests match byte-for-byte ($SRC_COUNT files)." else echo "ERROR: manifest mismatch, see /tmp/adolf-state-migration.diff" >&2 cat /tmp/adolf-state-migration.diff >&2 exit 1 fi echo echo "== Migration copy verified OK ==" echo "Source volume '$SRC_VOLUME' left untouched (not deleted, not modified)." echo "Next steps (NOT done by this script -- human-supervised, see kb#219 report):" echo " 1. Apply the docker-compose.yml bind-mount diff for the 'adolf' service." echo " 2. docker compose -f openai/docker-compose.yml config -q # validate" echo " 3. docker compose -f openai/docker-compose.yml up -d adolf # recreates container on new mount" echo " 4. Verify: docker inspect adolf shows /mnt/ssd/dbs/adolf/state, not the volume;" echo " Matrix session survives (no re-login), memory/config/persona intact." echo " 5. Only after a soak period: docker volume rm $SRC_VOLUME"