#!/usr/bin/env bash # # mc-refresh-restart.sh (adjusted for the /minecraft ZFS + HTTP-mirror build) # # 1. Syncs the packwiz pack against the live mods/ folder on the share # (adds new jars, re-pins changed ones, drops orphaned metadata) # 2. Refreshes index.toml # 3. Takes a ZFS snapshot of the share # 4. Restarts the Minecraft server via systemd (minecraft.service) # 5. Prunes snapshots to keep only the 8 most recent # # Requires: passwordless sudo for `systemctl restart minecraft` and `zfs`, # or run as root. packwiz must be in PATH. set -euo pipefail ### CONFIG ############################################### SHARE="/minecraft" # ZFS dataset mountpoint PACK_DIR="${SHARE}/packs/mypack" # packwiz project root (has pack.toml) MODS_DIR="${SHARE}/mods" # live mods, served by the HTTP mirror BASE_URL="https://games.abdelbaki.eu/mods" # mirror base URL, no trailing slash PACK_URL="https://games.abdelbaki.eu/packs/mypack" # where PACK_DIR is served; "" to skip the check SERVICE_NAME="minecraft.service" START_TIMEOUT=60 # seconds to wait for the service to report active KEEP_SNAPSHOTS=8 # how many snapshots to retain SNAP_PREFIX="packwiz" # snapshot name prefix REMOVE_ORPHANS=1 # drop .pw.toml whose jar is gone ########################################################### log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } die() { log "ERROR: $*"; exit 1; } # Run privileged. Root runs directly; anyone else goes through sudo. priv() { if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo "$@"; fi; } BASE_URL="${BASE_URL%/}" ### 0. Preflight ######################################### command -v systemctl >/dev/null 2>&1 || die "systemctl not found." command -v packwiz >/dev/null 2>&1 || die "packwiz not found in PATH" # An unmounted dataset leaves /minecraft as an empty dir on the root # filesystem. Refuse rather than snapshot nothing and archive nothing. mountpoint -q "${SHARE}" || die "${SHARE} is not a mountpoint — the ZFS dataset is not mounted. Check 'zpool status' and 'zfs mount -a'." [ -f "${PACK_DIR}/pack.toml" ] || die "no pack.toml in ${PACK_DIR}" [ -d "${MODS_DIR}" ] || die "mods dir not found: ${MODS_DIR}" DATASET="$(findmnt -no SOURCE "${SHARE}" 2>/dev/null || true)" [ -n "${DATASET}" ] || die "could not determine the dataset backing ${SHARE}" ### 1. Sync pack metadata against the live mods folder #### # Mods are sourced by URL with hashes pinned in the index, so `packwiz refresh` # alone will NOT notice a new or replaced jar — refresh only reindexes existing # .pw.toml files. Each jar has to be (re-)registered with `packwiz url add`. log "Syncing pack metadata against ${MODS_DIR}..." cd "${PACK_DIR}" shopt -s nullglob JARS=("${MODS_DIR}"/*.jar) shopt -u nullglob # Zero jars is legitimate — an empty pack, or every mod deliberately removed. # The orphan sweep below then clears the metadata to match. if (( ${#JARS[@]} > 0 )); then probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "${BASE_URL}/" || true)" probe="${probe:-000}" [ "${probe}" = "000" ] && die "cannot reach ${BASE_URL} — packwiz needs it to hash each mod" else log "WARNING: no .jar files in ${MODS_DIR} — pack will end up empty" fi added=0; updated=0; unchanged=0; FAILED=() declare -A SEEN=() for jar in "${JARS[@]}"; do file="$(basename "${jar}")" modname="$(printf '%s' "${file%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')" [ -n "${modname}" ] || modname="${file%.jar}" SEEN["${modname}"]=1 meta="mods/${modname}.pw.toml" enc="$(printf '%s' "${file}" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/+/%2B/g')" # Re-add only when the jar is new or its content changed. packwiz downloads # the URL to hash it, so skipping unchanged mods is what keeps this quick. if [ -f "${meta}" ]; then sha256="$(sha256sum "${jar}" | cut -d' ' -f1)" sha512="$(sha512sum "${jar}" | cut -d' ' -f1)" if grep -qF -e "${sha256}" -e "${sha512}" "${meta}"; then (( unchanged++ )) || true continue fi action="updated" else action="added" fi if packwiz url add "${modname}" "${BASE_URL}/${enc}" >/dev/null 2>&1; then log " ${action}: ${modname}" [ "${action}" = "added" ] && (( added++ )) || (( updated++ )) else log " FAILED: ${modname}" FAILED+=("${file}") fi done ### 1b. Drop metadata for jars that are gone ############## if (( REMOVE_ORPHANS )); then shopt -s nullglob for meta in mods/*.pw.toml; do modname="$(basename "${meta}" .pw.toml)" [ -n "${SEEN[${modname}]:-}" ] && continue log " orphaned, removing: ${modname}" packwiz remove "${modname}" >/dev/null 2>&1 || rm -f -- "${meta}" done shopt -u nullglob fi log "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged." if (( ${#FAILED[@]} > 0 )); then log "WARNING: ${#FAILED[@]} mod(s) failed to register:" printf ' %s\n' "${FAILED[@]}" die "refusing to restart with an incomplete pack — fix these first" fi ### 2. Refresh packwiz index ############################# log "Refreshing packwiz index..." packwiz refresh log "index.toml refreshed." ### 2b. Verify clients can actually see the new pack ###### # Clients pull pack.toml -> index.toml -> mods/*.pw.toml over HTTP. If PACK_DIR # isn't served, or a proxy is caching it, the server restarts onto a pack no # client can fetch — so compare what the mirror hands back against what's on # disk before touching the service. PACK_URL="${PACK_URL%/}" if [ -n "${PACK_URL}" ]; then log "Verifying ${PACK_URL}/pack.toml ..." remote="$(curl -fsS --max-time 10 -H 'Cache-Control: no-cache' "${PACK_URL}/pack.toml" 2>/dev/null || true)" if [ -z "${remote}" ]; then die "cannot fetch ${PACK_URL}/pack.toml — ${PACK_DIR} is not being served. Point the web root at it, or set PACK_URL=\"\" to skip this check." fi if [ "$(printf '%s' "${remote}" | sha256sum | cut -d' ' -f1)" \ = "$(sha256sum pack.toml | cut -d' ' -f1)" ]; then log "Mirror is serving the current pack.toml." else die "${PACK_URL}/pack.toml differs from ${PACK_DIR}/pack.toml. Usually a caching proxy. Serve pack.toml and index.toml with 'Cache-Control: no-cache', or purge the cache, then re-run." fi # index.toml is fetched as a separate request and cached separately. idx="$(curl -fsS --max-time 10 -o /dev/null -w '%{http_code}' "${PACK_URL}/index.toml" || true)" idx="${idx:-000}" [ "${idx}" = "200" ] || die "${PACK_URL}/index.toml returned HTTP ${idx} — clients cannot resolve the pack" fi ### 3. Snapshot the share ################################ # Taken BEFORE the restart so a server that fails to come up can be rolled # back to the exact state that last worked. TIMESTAMP="$(date '+%Y%m%d%H%M')" SNAPSHOT="${DATASET}@${SNAP_PREFIX}-${TIMESTAMP}" log "Snapshotting ${SNAPSHOT}..." if priv zfs snapshot "${SNAPSHOT}"; then log "Snapshot complete: ${SNAPSHOT}" else die "zfs snapshot failed" fi ### 4. Restart the server via systemd ##################### log "Restarting ${SERVICE_NAME}..." priv systemctl restart "${SERVICE_NAME}" waited=0 until systemctl is-active --quiet "${SERVICE_NAME}" || (( waited >= START_TIMEOUT )); do sleep 1 (( waited++ )) || true done if systemctl is-active --quiet "${SERVICE_NAME}"; then log "${SERVICE_NAME} is active." else log "WARNING: ${SERVICE_NAME} did not report active within ${START_TIMEOUT}s." log " Check: journalctl -u ${SERVICE_NAME} -n 50" log " Roll back: sudo systemctl stop ${SERVICE_NAME} && sudo zfs rollback ${SNAPSHOT}" fi ### 5. Prune old snapshots, keep newest N ################# log "Pruning snapshots, keeping newest ${KEEP_SNAPSHOTS}..." mapfile -t SNAPSHOTS < <( zfs list -H -o name -t snapshot -s creation "${DATASET}" 2>/dev/null \ | grep -- "@${SNAP_PREFIX}-" || true ) if (( ${#SNAPSHOTS[@]} > KEEP_SNAPSHOTS )); then # Sorted oldest-first by creation, so the excess is at the front. for old in "${SNAPSHOTS[@]:0:$(( ${#SNAPSHOTS[@]} - KEEP_SNAPSHOTS ))}"; do log "Destroying old snapshot: ${old}" priv zfs destroy -- "${old}" done else log "Nothing to prune (${#SNAPSHOTS[@]}/${KEEP_SNAPSHOTS})." fi log "Done."