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:
1 parent
00e9815307
commit
b9bcc13b73
5 files changed
+625
No files matched your search
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/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")"
|
||||
Reference in new issue
Block a user