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

+3
View File
@@ -43,3 +43,6 @@ docker/mc-status/mc-status
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh) # Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
certs/ certs/
# migration bundles (migrate-export.sh) — hold .env secrets + LE private keys
ulicraft-bundle-*.tar.gz
+163
View File
@@ -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 <repo> ~/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 `<dirname>_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 <name> # 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.
+147
View File
@@ -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 <ssh-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'
+130
View File
@@ -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")"
+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