Files
oierandClaude Opus 4.8 b9bcc13b73 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>
2026-07-23 00:41:39 +02:00

131 lines
5.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# migrate-export.sh — bundle the Ulicraft stack's PORTABLE state into a single
# tarball, ready to restore on another host with migrate-import.sh.
#
# Runs on YOUR WORKSTATION and pulls everything from the source host over ssh
# (default: cochi). Nothing is written on the source; it is read-only there.
#
# What goes in the bundle:
# .env secrets + config (RCON, FILES_* hashes, DRASL_*, OVH)
# certs/ Let's Encrypt cert+key per name (opt out: --without-certs)
# volume drasl_state accounts, passwords, skins, capes, signing key ← the point
# volume kuma_data Uptime Kuma monitors + history
# volume filestash_state filestash sqlite + search index
# MANIFEST.txt source host, project name, git commit, sha256sums
#
# Deliberately NOT included (regenerated or you deploy them yourself):
# mc_data (the world — opt in with --with-world), backups/, the distribution
# repo, generators-data/, www/, server-mods/, rendered configs.
#
# Usage:
# tooling/migrate-export.sh # from cochi, certs in, no world
# tooling/migrate-export.sh --from cochi --out ~/bundles
# tooling/migrate-export.sh --without-certs # reissue certs on the target instead
# tooling/migrate-export.sh --with-world # also carry the world (~15G)
#
# The output tarball contains private keys → it is written mode 0600. Keep it safe.
set -euo pipefail
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 ────────────────────────────────────────────────────────────
FROM="cochi"
SRC_DIR="/home/ubuntu/mc/ulicraft-server-v1"
OUT_DIR="."
WITH_CERTS=1
WITH_WORLD=0
while [ $# -gt 0 ]; do
case "$1" in
--from) FROM="${2:?}"; shift 2 ;;
--src-dir) SRC_DIR="${2:?}"; shift 2 ;;
--out) OUT_DIR="${2:?}"; shift 2 ;;
--without-certs) WITH_CERTS=0; shift ;;
--with-world) WITH_WORLD=1; shift ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) die "unknown arg: $1 (see --help)" ;;
esac
done
command -v ssh >/dev/null || die "ssh required"
command -v tar >/dev/null || die "tar required"
# ── discover the source: project name + git commit ─────────────────
log "inspecting source host '$FROM' at $SRC_DIR"
ssh "$FROM" "test -f '$SRC_DIR/.env'" || die "no .env at $SRC_DIR on $FROM (wrong --src-dir?)"
# Compose project name = the source checkout's dir basename (compose default).
PROJ="$(ssh "$FROM" "basename '$SRC_DIR'")"
[ -n "$PROJ" ] || die "could not resolve compose project name on $FROM"
COMMIT="$(ssh "$FROM" "cd '$SRC_DIR' && git rev-parse --short HEAD 2>/dev/null || echo unknown")"
warn "source compose project: $PROJ (git $COMMIT)"
# volumes to carry (bare suffixes; prefixed with $PROJ on the source)
VOLS=(drasl_state kuma_data filestash_state)
[ "$WITH_WORLD" -eq 1 ] && VOLS+=(mc_data)
# Verify every volume exists on the source before we start streaming.
for v in "${VOLS[@]}"; do
ssh "$FROM" "docker volume inspect '${PROJ}_${v}' >/dev/null 2>&1" \
|| die "volume ${PROJ}_${v} not found on $FROM — is the stack the one at $SRC_DIR?"
done
# ── staging dir on the workstation ──────────────────────────────────
STAMP="$(date +%Y%m%d-%H%M%S)"
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/ulicraft-migrate.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$OUT_DIR"
# .env
log "pulling .env"
ssh "$FROM" "cat '$SRC_DIR/.env'" > "$STAGE/.env"
[ -s "$STAGE/.env" ] || die ".env came back empty"
# certs/
if [ "$WITH_CERTS" -eq 1 ]; then
if ssh "$FROM" "test -d '$SRC_DIR/certs'"; then
log "pulling certs/ (Let's Encrypt cert+key per name)"
ssh "$FROM" "tar -C '$SRC_DIR' -czf - certs" > "$STAGE/certs.tar.gz"
else
warn "no certs/ dir on source — skipping (you'll issue certs on the target)"
WITH_CERTS=0
fi
else
warn "--without-certs — certs excluded; issue-letsencrypt.sh on the target"
fi
# docker volumes → one gzip'd tar each, streamed over ssh (never staged on source)
for v in "${VOLS[@]}"; do
log "dumping volume ${PROJ}_${v}"
ssh "$FROM" "docker run --rm -v '${PROJ}_${v}':/v alpine tar -C /v -czf - ." \
> "$STAGE/vol-${v}.tar.gz"
[ -s "$STAGE/vol-${v}.tar.gz" ] || die "volume ${v} dumped empty — aborting"
done
# ── manifest with checksums ─────────────────────────────────────────
log "writing MANIFEST.txt"
{
echo "ulicraft migration bundle"
echo "created: $STAMP"
echo "source_host: $FROM"
echo "source_dir: $SRC_DIR"
echo "source_project: $PROJ"
echo "git_commit: $COMMIT"
echo "with_certs: $WITH_CERTS"
echo "with_world: $WITH_WORLD"
echo "volumes: ${VOLS[*]}"
echo
echo "sha256:"
( cd "$STAGE" && sha256sum ./.env ./*.tar.gz 2>/dev/null )
} > "$STAGE/MANIFEST.txt"
# ── seal the bundle (0600 — it holds private keys + secrets) ────────
OUT="$OUT_DIR/ulicraft-bundle-${FROM}-${STAMP}.tar.gz"
log "sealing $OUT"
tar -C "$STAGE" -czf "$OUT" .
chmod 600 "$OUT"
SIZE="$(du -h "$OUT" | cut -f1)"
log "done — $OUT ($SIZE)"
printf ' next: copy it to the target and run tooling/migrate-import.sh %s\n' "$(basename "$OUT")"