#!/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")"