feat(tooling): host migration scripts (export/import/check)

Move the stack to a new host keeping the same domain, carrying Drasl
users + skins (+ kuma monitors, filestash index) but not the world.

- migrate-check.sh: validate the target meets requirements. [restore]
  hard-fails (docker, compose v2, git, tar, ssh); [deploy] warns
  (node/pnpm, py>=3.11, nginx, acme.sh, RAM, disk, ports). --host runs
  it remotely by streaming itself over ssh.
- migrate-export.sh: workstation-run, pulls the source read-only over
  ssh into one 0600 bundle (.env, certs/, drasl_state/kuma_data/
  filestash_state volumes, checksummed MANIFEST). Certs in by default
  (--without-certs); world opt-in (--with-world).
- migrate-import.sh: target-run, sha256-verify, refuse existing volumes
  (--force), backup+restore .env with --path-prefix rewrite, check host
  paths resolve, restore certs+volumes, print the manual tail.
- plan/22-migration.md: human walkthrough (cochi -> oxps), rollback
  window warning, rehearsal notes.
- .gitignore: ignore ulicraft-bundle-*.tar.gz (holds secrets + LE keys).

Assumes Compose v2 on the target (no update-all.sh changes). Distribution
repo, TLS issuance, DNS and firewall stay manual. Round-trip tested
against prod: 9.2M bundle, drasl.db restored byte-exact, clobber guard
verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oierandClaude Opus 4.8 committed 2026-07-23 00:41:39 +02:00
1 parent 00e9815307
commit b9bcc13b73
5 files changed
+625

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# migrate-check.sh — validate that a machine meets the requirements to RESTORE
# and RUN the Ulicraft stack. Run it on the TARGET host (e.g. oxps) before a
# migration, or against a remote host with --host.
#
# Two tiers of checks:
# [restore] hard requirements — without these migrate-import.sh cannot run.
# Any failure exits non-zero.
# [deploy] needed to build + serve the full stack (update-all.sh, TLS).
# Missing ones WARN only — you can restore now, install later.
#
# Usage:
# tooling/migrate-check.sh # check THIS machine
# tooling/migrate-check.sh --host oxps # check a remote host over ssh
#
# The --host form streams this very script to the remote and runs it there, so
# nothing needs to be installed on the remote first.
set -uo pipefail # NOT -e: we want to run every check and total the failures
# ── remote mode: ship self to the host and run it there ─────────────
if [ "${1:-}" = "--host" ]; then
[ -n "${2:-}" ] || { echo "usage: $0 --host <ssh-host>" >&2; exit 2; }
exec ssh "$2" 'bash -s' < "$0"
fi
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[1;32m✓\033[0m %s\n' "$*"; }
bad() { printf ' \033[1;31m✗\033[0m %s\n' "$*"; }
warn() { printf ' \033[1;33m!\033[0m %s\n' "$*"; }
HARD_FAIL=0
SOFT_FAIL=0
# require CMD "human hint" — hard [restore] check
require() {
if command -v "$1" >/dev/null 2>&1; then ok "$1 present"; else
bad "$1 MISSING — $2"; HARD_FAIL=$((HARD_FAIL+1)); fi
}
# want CMD "human hint" — soft [deploy] check
want() {
if command -v "$1" >/dev/null 2>&1; then ok "$1 present"; else
warn "$1 missing — $2"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
}
log "[restore] core tools"
require docker "install Docker Engine"
require git "install git"
require tar "install tar"
require gzip "install gzip"
require ssh "install openssh-client (used to pull the bundle)"
log "[restore] docker daemon reachable"
if docker info >/dev/null 2>&1; then ok "docker daemon up"; else
bad "cannot talk to the docker daemon (permission? is it running?)"; HARD_FAIL=$((HARD_FAIL+1)); fi
log "[restore] docker compose v2 plugin"
# The stack is authored for Compose v2 (docker compose ...). The legacy v1
# docker-compose binary is NOT accepted — it mangles the project name and
# diverges on --build.
if cv="$(docker compose version --short 2>/dev/null)"; then
major="${cv%%.*}"
if [ "${major:-0}" -ge 2 ] 2>/dev/null; then
ok "docker compose v$cv"
else
bad "docker compose reports v$cv — need v2+"; HARD_FAIL=$((HARD_FAIL+1))
fi
else
bad "'docker compose' v2 plugin not found. Install it:
mkdir -p ~/.docker/cli-plugins
curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-\$(uname -m) \\
-o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose
(the legacy 'docker-compose' v1 binary is NOT accepted)"
HARD_FAIL=$((HARD_FAIL+1))
fi
log "[deploy] build + config tools"
want envsubst "part of gettext — render-config.sh / render-nginx.sh need it"
want rsync "used to publish the distribution repo"
want openssl "used to mint FILES_PASSWORD_HASH"
want jq "sync-server-mods.sh falls back to python3 if absent"
want node "landing build (pnpm build) — install via nvm"
want pnpm "landing build — never npm (see project rules)"
log "[deploy] python >= 3.11 (build-modlist.py)"
PY=""
for c in python3.13 python3.12 python3.11 python3; do
if command -v "$c" >/dev/null 2>&1; then
v="$($c -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null)"
case "$v" in 3.1[1-9]|3.[2-9]*) PY="$c $v"; break;; esac
fi
done
if [ -n "$PY" ]; then ok "python $PY"; else
warn "no python >= 3.11 found — build-modlist.py will refuse"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
log "[deploy] TLS front (host nginx + acme.sh)"
want nginx "host nginx terminates TLS in front of caddy"
if [ -x "$HOME/.acme.sh/acme.sh" ] || command -v acme.sh >/dev/null 2>&1; then
ok "acme.sh present"
else
warn "acme.sh missing — needed to RENEW certs (bundle covers the first ~90d):
curl https://get.acme.sh | sh -s email=you@example.com"
SOFT_FAIL=$((SOFT_FAIL+1))
fi
log "[deploy] memory (MAX_MEMORY: 10G in compose)"
if command -v free >/dev/null 2>&1; then
mem_g="$(free -g | awk '/^Mem:/{print $2}')"
if [ "${mem_g:-0}" -ge 12 ] 2>/dev/null; then ok "${mem_g}G RAM"; else
warn "only ${mem_g}G RAM — the JVM alone wants 10G; lower MAX_MEMORY or add RAM"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
else
warn "cannot read RAM (no 'free')"; SOFT_FAIL=$((SOFT_FAIL+1))
fi
log "[deploy] free disk on / (need ~10G for images + volumes)"
if command -v df >/dev/null 2>&1; then
free_g="$(df -BG --output=avail / 2>/dev/null | awk 'NR==2{gsub("G","");print $1}')"
if [ "${free_g:-0}" -ge 10 ] 2>/dev/null; then ok "${free_g}G free on /"; else
warn "only ${free_g}G free on / — images + volumes want ~10G"; SOFT_FAIL=$((SOFT_FAIL+1)); fi
fi
log "[deploy] ports free (server + ingress)"
if command -v ss >/dev/null 2>&1; then
for p in 25565 80 443 "${CADDY_HTTP_PORT:-8880}"; do
if ss -ltn "( sport = :$p )" 2>/dev/null | grep -q ":$p"; then
warn "tcp/$p already in use — will collide unless it's this stack"; SOFT_FAIL=$((SOFT_FAIL+1))
else ok "tcp/$p free"; fi
done
if ss -lun "( sport = :24454 )" 2>/dev/null | grep -q ":24454"; then
warn "udp/24454 already in use (Simple Voice Chat)"; SOFT_FAIL=$((SOFT_FAIL+1))
else ok "udp/24454 free"; fi
else
warn "cannot check ports (no 'ss')"; SOFT_FAIL=$((SOFT_FAIL+1))
fi
# ── verdict ─────────────────────────────────────────────────────────
printf '\n'
if [ "$HARD_FAIL" -gt 0 ]; then
printf '\033[1;31m✗ %d restore-blocking requirement(s) missing — fix before migrate-import.sh\033[0m\n' "$HARD_FAIL"
[ "$SOFT_FAIL" -gt 0 ] && printf '\033[1;33m (+%d deploy warning(s))\033[0m\n' "$SOFT_FAIL"
exit 1
fi
if [ "$SOFT_FAIL" -gt 0 ]; then
printf '\033[1;33m✓ restore requirements met, but %d deploy warning(s) — you can import now, but\n install the missing pieces before ./update-all.sh --hard\033[0m\n' "$SOFT_FAIL"
exit 0
fi
printf '\033[1;32m✓ all checks passed — ready to migrate\033[0m\n'