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>
191 lines
8.8 KiB
Bash
Executable File
191 lines
8.8 KiB
Bash
Executable File
#!/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>
|
|
|
|
Fresh-world gotcha: if minecraft crash-loops on a fresh mc_data with
|
|
"AccessDeniedException: /data/kubejs/config" (ConsoleJS.STARTUP null), Docker
|
|
created /data/kubejs as root while the container runs as uid 1000. Fix once:
|
|
docker compose stop minecraft
|
|
docker run --rm -v ${PROJ}_mc_data:/data alpine chown 1000:1000 /data/kubejs
|
|
docker compose up -d minecraft
|
|
Details: plan/22-migration.md (Troubleshooting).
|
|
|
|
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
|