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
@@ -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
|
||||||
@@ -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.
|
||||||
Executable
+147
@@ -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'
|
||||||
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")"
|
||||||
Executable
+182
@@ -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
|
||||||
Reference in new issue
Block a user