diff --git a/.gitignore b/.gitignore index dfde706..c1e124f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ docker/mc-status/mc-status # Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh) certs/ + +# migration bundles (migrate-export.sh) — hold .env secrets + LE private keys +ulicraft-bundle-*.tar.gz diff --git a/plan/22-migration.md b/plan/22-migration.md new file mode 100644 index 0000000..6f311ef --- /dev/null +++ b/plan/22-migration.md @@ -0,0 +1,163 @@ +# Migrating the stack to another host + +Move the Ulicraft stack from one machine to another **keeping the same domain**, +carrying the **Drasl users + skins** (and monitors + file-share index), but +**not the Minecraft world**. Written for the cochi → oxps move, but the scripts +take `--from` / paths so it generalises. + +Three scripts do the mechanical work; the rest is deploying the distribution +repo, TLS, DNS, and firewall — the same one-time setup any fresh host needs. + +| script | runs on | does | +|---|---|---| +| `tooling/migrate-check.sh` | target (or `--host`) | validate the machine meets the requirements | +| `tooling/migrate-export.sh` | **your workstation** | pull state from the source host into one bundle | +| `tooling/migrate-import.sh` | target | restore that bundle into this host's stack | + +## What actually moves + +**In the bundle** (`migrate-export.sh`): + +- `.env` — all secrets (RCON, `FILES_*` hashes, `DRASL_ADMIN_*`, OVH DNS API). +- `certs/` — the Let's Encrypt cert+key for every name (opt out `--without-certs`). +- volume `drasl_state` — **accounts, passwords, skins, capes, signing key**. 144K. + This is the whole point of the migration. +- volume `kuma_data` — Uptime Kuma monitors + history (~38M). +- volume `filestash_state` — filestash sqlite + search index (~700K). + +**You provide on the target** (not in the bundle — too big, or you rebuild it): + +- The **distribution repo** checkout at `DISTRIBUTION_WEB_ROOT` (~3.3G). The + `minecraft` service bind-mounts kubejs source dirs from it and **will not boot** + until it exists. You deploy this yourself (Gitea deploy key is per-repo, per host). +- `generators-data/` (~109M, needs WAN) via `tooling/sync-generators-data.sh`. +- `runtime/authlib-injector.jar`, `launcher/` via their `fetch-*.sh` scripts. + +**Deliberately dropped:** the world (`mc_data`) and `backups/` — you said the +world isn't needed. (Carry the world anyway with `migrate-export.sh --with-world`.) +`www/`, `server-mods/`, and rendered configs are regenerated by `update-all.sh`. + +## Requirements on the target + +`migrate-check.sh` splits them: + +- **`[restore]` (hard)** — docker daemon, **Compose v2** (`docker compose`, not the + legacy `docker-compose` v1 binary), git, tar, gzip, ssh. Import refuses without these. +- **`[deploy]` (warn)** — envsubst, rsync, node+pnpm, python ≥ 3.11, nginx, + acme.sh, ≥12G RAM (`MAX_MEMORY: 10G`), ~10G free disk, free ports. You can + restore without these, but need them before `./update-all.sh --hard`. + +Installing Compose v2 by hand (if the host only has v1): + +```bash +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 +docker compose version # confirm v2+ +``` + +## Step by step (cochi → oxps) + +### 0. Check the target + +```bash +tooling/migrate-check.sh --host oxps +``` + +Fix every `✗`. Warnings are fine to defer to step 5. + +### 1. Export (on your workstation) + +```bash +tooling/migrate-export.sh --from cochi --out ~/bundles +# → ~/bundles/ulicraft-bundle-cochi-YYYYMMDD-HHMMSS.tar.gz (mode 0600) +``` + +Reads the source read-only; writes nothing there. The bundle holds private keys +and `.env` secrets — it is created `0600`, keep it that way. + +### 2. Get the repo + bundle onto the target + +```bash +# clone the stack repo on oxps (add oxps's Gitea deploy key first) +git clone ~/mc/ulicraft-server && cd ~/mc/ulicraft-server +# copy the bundle over +scp ~/bundles/ulicraft-bundle-cochi-*.tar.gz oxps:~/mc/ulicraft-server/ +``` + +The checkout **directory name is the Compose project name** (here +`ulicraft-server`). Volumes restore as `_drasl_state`, and +`docker compose` later looks for exactly that — so whatever you name the dir, +be consistent. (Pin `COMPOSE_PROJECT_NAME` in `.env` if you want it fixed.) + +### 3. Import (on the target) + +```bash +cd ~/mc/ulicraft-server +tooling/migrate-import.sh ulicraft-bundle-cochi-*.tar.gz \ + --path-prefix /home/ubuntu/mc=/home/$USER/mc +``` + +`--path-prefix OLD=NEW` rewrites the source-absolute host paths in `.env` +(`DISTRIBUTION_WEB_ROOT`, `GENERATORS_REPO`, `GENERATORS_DATA_DIR`, +`FILES_DATA_DIR`) to where they live on oxps. Import then checks each one exists +and **warns loudly** about any that don't — `DISTRIBUTION_WEB_ROOT` especially, +since the server won't boot without it. + +Guards: it refuses to overwrite volumes that already exist (pass `--force` to +replace them — a clean wipe, not a merge), backs up any existing `.env`, and +verifies the bundle's sha256 checksums before touching anything. + +### 4. Deploy the distribution repo + +Clone/publish the distribution repo to the `DISTRIBUTION_WEB_ROOT` path from +step 3. Two checkouts drift — publish deliberately, don't blind-rsync from cochi. +Add oxps's own Gitea deploy key for that repo. + +### 5. Finish the host setup + +```bash +tooling/fetch-authlib.sh +tooling/fetch-launcher.sh +tooling/sync-generators-data.sh # needs WAN +tooling/render-nginx.sh --install # host nginx vhost → caddy +tooling/issue-letsencrypt.sh # seed acme.sh + its renewal cron +# firewall: tcp 25565, 80, 443, CADDY_HTTP_PORT ; udp 24454 +``` + +> **Bundle certs are a bridge, not a renewal.** The certs from the bundle let +> nginx start immediately, but nothing renews them — acme.sh's state lived on +> cochi. Run `issue-letsencrypt.sh` on oxps once so renewals happen here. It uses +> the OVH **DNS-01** challenge, so it works before DNS points at oxps and needs +> no inbound ports. + +### 6. Cut over + +```bash +./update-all.sh --hard # render → sync → build → up +docker compose ps # all services healthy +``` + +Point DNS `${BASE_DOMAIN}` + subdomains at oxps. Once a player has joined: + +```bash +docker compose exec minecraft rcon-cli op # OPS env breaks on Drasl UUIDs +``` + +## Rollback window — one way, mind the clock + +Same domain ⇒ DNS points at **one** host. cochi stays as a fallback, but the +moment a player **registers or changes a skin on oxps**, `drasl.db` diverges from +cochi's copy. Rolling back to cochi then **silently loses** those new +accounts/skins. Safe for hours as a fallback; not for weeks. If you must roll +back after real use, re-export from oxps and re-import onto cochi first. + +## Re-running / testing + +Everything is idempotent-ish and safe to rehearse: + +- `migrate-export.sh` never writes on the source; run it as often as you like. +- `migrate-import.sh` refuses to clobber existing volumes without `--force`, so a + second run on a populated host stops instead of nuking data. To rehearse a full + restore on a throwaway host, import, inspect, `docker compose down -v`, repeat. diff --git a/tooling/migrate-check.sh b/tooling/migrate-check.sh new file mode 100755 index 0000000..9ca5bde --- /dev/null +++ b/tooling/migrate-check.sh @@ -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 " >&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' diff --git a/tooling/migrate-export.sh b/tooling/migrate-export.sh new file mode 100755 index 0000000..a247a5c --- /dev/null +++ b/tooling/migrate-export.sh @@ -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")" diff --git a/tooling/migrate-import.sh b/tooling/migrate-import.sh new file mode 100755 index 0000000..b8db4b2 --- /dev/null +++ b/tooling/migrate-import.sh @@ -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}_ — 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 <\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 + +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