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

+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# migrate-import.sh — restore a bundle made by migrate-export.sh onto THIS host.
#
# Run it from inside the target repo checkout (the compose project directory).
# It restores .env, certs/, and the docker volumes into THIS host's compose
# project, then prints the manual steps that finish the cutover.
#
# It assumes the machine already meets the requirements — run
# tooling/migrate-check.sh
# first. It does NOT deploy the distribution repo, issue certs, or start the
# stack; those stay in your hands (see the printed tail + plan/22-migration.md).
#
# Usage:
# tooling/migrate-import.sh BUNDLE.tar.gz
# tooling/migrate-import.sh BUNDLE.tar.gz --path-prefix /home/ubuntu/mc=/home/you/mc
# tooling/migrate-import.sh BUNDLE.tar.gz --without-certs
# tooling/migrate-import.sh BUNDLE.tar.gz --force # overwrite existing volumes
#
# --path-prefix OLD=NEW rewrites the source-absolute paths in .env
# (DISTRIBUTION_WEB_ROOT, GENERATORS_REPO, GENERATORS_DATA_DIR, FILES_DATA_DIR)
# to where they live on this host. Repeatable.
set -euo pipefail
cd "$(dirname "$0")/.." # target repo root
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
# ── args ────────────────────────────────────────────────────────────
BUNDLE=""
WITH_CERTS=1
FORCE=0
declare -a PREFIXES=()
while [ $# -gt 0 ]; do
case "$1" in
--without-certs) WITH_CERTS=0; shift ;;
--force) FORCE=1; shift ;;
--path-prefix) PREFIXES+=("${2:?}"); shift 2 ;;
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
-*) die "unknown flag: $1 (see --help)" ;;
*) [ -z "$BUNDLE" ] || die "extra arg: $1"; BUNDLE="$1"; shift ;;
esac
done
[ -n "$BUNDLE" ] || die "usage: $0 BUNDLE.tar.gz [--path-prefix OLD=NEW] [--without-certs] [--force]"
[ -f "$BUNDLE" ] || die "bundle not found: $BUNDLE"
BUNDLE="$(cd "$(dirname "$BUNDLE")" && pwd)/$(basename "$BUNDLE")" # absolutize
command -v docker >/dev/null || die "docker required"
docker info >/dev/null 2>&1 || die "cannot reach the docker daemon"
docker compose version >/dev/null 2>&1 || die "docker compose v2 plugin required (see migrate-check.sh)"
[ -f docker-compose.yml ] || die "run this from inside the target repo checkout (no docker-compose.yml here)"
# ── target compose project name (default: this dir's basename) ──────
PROJ="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
log "target compose project: $PROJ (in $PWD)"
warn "volumes will be restored as ${PROJ}_<name> — this MUST match what 'docker compose' uses here."
warn "if that looks wrong, set COMPOSE_PROJECT_NAME in .env and re-run."
# ── unpack the bundle to a temp staging dir ─────────────────────────
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/ulicraft-import.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
log "unpacking $(basename "$BUNDLE")"
tar -C "$STAGE" -xzf "$BUNDLE"
[ -f "$STAGE/MANIFEST.txt" ] || die "no MANIFEST.txt in bundle — is this a ulicraft migration bundle?"
log "manifest:"
sed 's/^/ /' "$STAGE/MANIFEST.txt"
# ── verify checksums ────────────────────────────────────────────────
log "verifying sha256 checksums"
( cd "$STAGE" && awk '/^[0-9a-f]{64} /{print}' MANIFEST.txt | sha256sum -c - ) \
|| die "checksum mismatch — bundle is corrupt or truncated"
# volumes present in the bundle
mapfile -t VOL_FILES < <(cd "$STAGE" && ls vol-*.tar.gz 2>/dev/null || true)
[ "${#VOL_FILES[@]}" -gt 0 ] || die "no volume archives in bundle"
# ── refuse to clobber existing volumes unless --force ───────────────
CLASH=0
for f in "${VOL_FILES[@]}"; do
name="${f#vol-}"; name="${name%.tar.gz}"
if docker volume inspect "${PROJ}_${name}" >/dev/null 2>&1; then
warn "volume ${PROJ}_${name} already exists on this host"
CLASH=1
fi
done
if [ "$CLASH" -eq 1 ] && [ "$FORCE" -eq 0 ]; then
die "existing volumes would be overwritten. Re-run with --force to replace them
(their current contents are discarded). If this is the WRONG host, stop now."
fi
# ── restore .env (backup any existing one first) ────────────────────
if [ -f .env ]; then
BK=".env.bak.pre-migrate.$(date +%Y%m%d-%H%M%S)"
warn "existing .env → $BK"
cp -a .env "$BK"
fi
log "restoring .env"
cp "$STAGE/.env" .env
chmod 600 .env
# apply --path-prefix rewrites
for p in "${PREFIXES[@]}"; do
old="${p%%=*}"; new="${p#*=}"
[ -n "$old" ] && [ "$old" != "$p" ] || die "bad --path-prefix '$p' (want OLD=NEW)"
log "rewriting .env paths: $old -> $new"
# only touch the RHS of KEY=... lines; escape / for sed
esc_old="$(printf '%s' "$old" | sed 's/[.[\*^$/]/\\&/g')"
esc_new="$(printf '%s' "$new" | sed 's/[&/]/\\&/g')"
sed -i "s/${esc_old}/${esc_new}/g" .env
done
# ── sanity-check the host-path vars in .env resolve on THIS host ─────
log "checking host paths referenced by .env"
set -a; . ./.env; set +a
MISSING=0
for var in DISTRIBUTION_WEB_ROOT GENERATORS_REPO GENERATORS_DATA_DIR FILES_DATA_DIR; do
val="${!var:-}"
[ -n "$val" ] || { warn "$var unset in .env"; continue; }
if [ -e "$val" ]; then
printf ' \033[1;32m✓\033[0m %s = %s\n' "$var" "$val"
else
printf ' \033[1;33m!\033[0m %s = %s (does NOT exist here)\n' "$var" "$val"
MISSING=$((MISSING+1))
fi
done
if [ "$MISSING" -gt 0 ]; then
warn "$MISSING path(s) above don't exist on this host yet."
warn "DISTRIBUTION_WEB_ROOT especially: the minecraft service bind-mounts kubejs"
warn "from it and WON'T BOOT until it's populated. Deploy the distribution repo"
warn "(and re-run with --path-prefix if the paths belong elsewhere) before starting."
fi
# ── restore certs/ ──────────────────────────────────────────────────
if [ "$WITH_CERTS" -eq 1 ] && [ -f "$STAGE/certs.tar.gz" ]; then
log "restoring certs/"
[ -d certs ] && warn "existing certs/ will be merged/overwritten from the bundle"
tar -C . -xzf "$STAGE/certs.tar.gz"
elif [ "$WITH_CERTS" -eq 0 ]; then
warn "--without-certs — not restoring certs; run tooling/issue-letsencrypt.sh here"
else
warn "bundle has no certs.tar.gz — run tooling/issue-letsencrypt.sh here"
fi
# ── restore the docker volumes ──────────────────────────────────────
for f in "${VOL_FILES[@]}"; do
name="${f#vol-}"; name="${name%.tar.gz}"
vol="${PROJ}_${name}"
log "restoring volume $vol"
docker volume create "$vol" >/dev/null
if [ "$FORCE" -eq 1 ]; then
# wipe first so a --force restore is a clean replace, not a merge
docker run --rm -v "$vol":/v alpine sh -c 'rm -rf /v/* /v/..?* /v/.[!.]* 2>/dev/null || true'
fi
docker run --rm -i -v "$vol":/v alpine tar -C /v -xzf - < "$STAGE/$f"
done
# ── done: print the manual tail ─────────────────────────────────────
cat <<EOF
$(printf '\033[1;32m==>\033[0m volumes + .env + certs restored.')
Remaining manual steps (see plan/22-migration.md for detail):
1. Deploy the distribution repo so DISTRIBUTION_WEB_ROOT is populated
(the minecraft service bind-mounts kubejs from it; it won't boot empty).
Also add this host's Gitea deploy key for that repo.
2. Fetch vendored bits: tooling/fetch-authlib.sh tooling/fetch-launcher.sh
and (needs WAN) tooling/sync-generators-data.sh
3. Host nginx vhost: tooling/render-nginx.sh --install
4. Certificates: tooling/issue-letsencrypt.sh
(bundle certs boot nginx now, but nothing renews them until you run this)
5. Open the firewall: tcp 25565, 80, 443, ${CADDY_HTTP_PORT:-8880} ; udp 24454
6. Point DNS ${BASE_DOMAIN:-\$BASE_DOMAIN} + subdomains at THIS host.
7. Bring it up: ./update-all.sh --hard
8. Re-op players once they've joined: docker compose exec minecraft rcon-cli op <name>
Rollback note: same domain means DNS points at ONE host. Once a player registers
or changes a skin here, this drasl.db diverges from cochi — rolling back to cochi
silently loses those changes. Keep the fallback window short.
EOF