Files
oierandClaude Opus 4.8 05a98efe57 docs(migration): fresh-world kubejs root-owned crash-loop + fix
On a fresh mc_data volume the minecraft service crash-loops with
"AccessDeniedException: /data/kubejs/config" (ConsoleJS.STARTUP null NPE):
Docker auto-creates the bind-mount parent /data/kubejs as root:root, but
the itzg container runs as uid 1000, so KubeJS can't create its writable
siblings (config/cache/generated/logs). Only bites fresh worlds — cochi's
aged volume predates the ro submounts and is already 1000-owned.

Fix (durable): chown 1000:1000 /data/kubejs once, then restart.

- plan/22-migration.md: Troubleshooting section with symptom + fix.
- migrate-import.sh: manual tail now flags it with the exact commands.

Hit + resolved during the cochi->oxps migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:38:13 +02:00

199 lines
8.1 KiB
Markdown

# 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.
## Troubleshooting
### minecraft crash-loops on a fresh host: `AccessDeniedException: /data/kubejs/config`
Symptom (in `docker compose logs minecraft`), restarting every ~15s:
```
java.nio.file.AccessDeniedException: /data/kubejs/config
Caused by: NullPointerException: Cannot invoke "...ConsoleJS.error(...)"
because "dev.latvian.mods.kubejs.script.ConsoleJS.STARTUP" is null
```
**Cause.** The compose file bind-mounts the kubejs *source* subdirs read-only
(`server_scripts`, `startup_scripts`, `data`, `assets`). To place those mounts,
Docker auto-creates their parent `/data/kubejs` — and on a **fresh `mc_data`
volume** it creates it as **root:root**. The itzg container runs as **uid 1000**,
so KubeJS can't create its writable siblings (`config/`, `cache/`, `generated/`,
`logs/`) → init aborts → `ConsoleJS.STARTUP` stays null → NPE → exit 1.
This only bites a **fresh world** (migration, new host, wiped `mc_data`). On an
aged host like cochi `/data/kubejs` predates the ro submounts and is already
1000-owned, so it never surfaced there.
**Fix** — chown the dir to 1000 once (durable; survives restarts):
```bash
docker compose stop minecraft
docker run --rm -v <project>_mc_data:/data alpine chown 1000:1000 /data/kubejs
docker compose up -d minecraft
```
`<project>` is the checkout dir basename (e.g. `ulicraft-server`). Distinct from
the "don't mount the whole kubejs dir ro" gotcha in `plan/05-minecraft.md` — same
mod, different trigger (ownership of the auto-created parent, not a ro root).
## 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.