diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go new file mode 100644 index 0000000..3f44585 --- /dev/null +++ b/bootstrap_asset_test.go @@ -0,0 +1,74 @@ +package felis + +import ( + "strings" + "testing" +) + +// The lobby's LuckPerms wiring is a three-file contract with no compiler behind it: +// bootstrap.sh resolves a URL and passes it as a build-arg, the Dockerfile requires +// that exact arg name and writes the jar to a fixed path, and entrypoint.sh copies +// from that same path on every boot. A typo in any one of them is invisible until a +// lobby actually starts — and then `set -e` turns the failed cp into a crashloop on +// the always-on hub every authenticated player is transferred to. +// +// All three files ship inside the binary (gameStackAssets, bootstrapScript) for the +// TUI install path that has no source checkout, so reading them back here checks +// what is actually shipped rather than what is merely on disk. +func TestLobbyLuckPermsWiringIsConsistent(t *testing.T) { + dockerfile := readGameStackFile(t, "deploy/lobby/Dockerfile") + entrypoint := readGameStackFile(t, "deploy/lobby/entrypoint.sh") + script := BootstrapScript() + + // The path is the contract. RUNTIME_DIR is /paper in the entrypoint, so the + // Dockerfile's download target and the boot-time copy source must be the same + // string; anything else fails at `cp`, not at build. + const jarPath = "/paper/plugins/LuckPerms.jar" + if !strings.Contains(dockerfile, "-o "+jarPath) { + t.Errorf("Dockerfile does not download LuckPerms to %s", jarPath) + } + if !strings.Contains(entrypoint, `cp -f "$RUNTIME_DIR/plugins/LuckPerms.jar"`) { + t.Error("entrypoint.sh does not refresh LuckPerms.jar from the image seed; " + + "a lobby would keep whatever stale jar the PVC happens to hold") + } + if !strings.Contains(entrypoint, `RUNTIME_DIR="/paper"`) { + t.Error(`RUNTIME_DIR is no longer "/paper", so the copy above no longer ` + + "resolves to the path the Dockerfile writes") + } + + // The build-arg name has to agree across the two files that never see each other. + const arg = "LUCKPERMS_JAR_URL" + if !strings.Contains(dockerfile, "ARG "+arg) { + t.Errorf("Dockerfile declares no ARG %s", arg) + } + if !strings.Contains(script, "--build-arg "+arg+"=") { + t.Errorf("bootstrap.sh never passes --build-arg %s", arg) + } + if !strings.Contains(script, "LUCKPERMS_JAR_URL=\"$(luckperms_latest_jar)\"") { + t.Error("bootstrap.sh does not resolve the LuckPerms URL before building") + } + + // bukkit-legacy targets Minecraft 1.8-1.12 and the other platforms are not + // loadable by Paper at all, so the resolver's grep must pin the /bukkit/ path + // segment — the metadata endpoint returns every platform's URL in one payload. + if !strings.Contains(script, "/bukkit/loader/") { + t.Error("luckperms_latest_jar does not pin the bukkit/loader path; it could " + + "return the fabric, forge or velocity jar, none of which Paper can load") + } + + // A missing jar must stop the build. The alternative — shipping a lobby that + // starts fine and answers every `lp` command from the panel's permission screen + // with "Unknown command" — is discovered in production. + if !strings.Contains(dockerfile, `if [ -z "${LUCKPERMS_JAR_URL:-}" ]`) { + t.Error("Dockerfile does not fail the build when LUCKPERMS_JAR_URL is unset") + } +} + +func readGameStackFile(t *testing.T, name string) string { + t.Helper() + b, err := gameStackAssets.ReadFile(name) + if err != nil { + t.Fatalf("read embedded %s: %v", name, err) + } + return string(b) +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 676b84b..f4c2286 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1181,7 +1181,31 @@ resolve_game_jars() { log "resolving the newest Paper ${MC_VERSION} build" PAPER_JAR_URL="$(papermc_latest_jar paper "$MC_VERSION")" \ || die "could not resolve a Paper build for Minecraft ${MC_VERSION} (the login gate pins this protocol; the build likely exists — Fill upstream is down or flapping)" - ok "Limbo ${LIMBO_VERSION} + Paper, both on Minecraft ${MC_VERSION}" + # LuckPerms is not version-matched to MC_VERSION the way Paper is: it ships one + # current Bukkit build that supports the whole supported Minecraft range, so there is + # no per-version endpoint to ask. + log "resolving the newest LuckPerms build" + LUCKPERMS_JAR_URL="$(luckperms_latest_jar)" \ + || die "could not resolve a LuckPerms build (metadata.luckperms.net is down or flapping); the lobby needs it for the panel's permission controls" + ok "Limbo ${LIMBO_VERSION} + Paper, both on Minecraft ${MC_VERSION}; LuckPerms resolved" +} + +# luckperms_latest_jar prints the download URL of the current LuckPerms Bukkit build. +# Bukkit, not bukkit-legacy: legacy targets Minecraft 1.8-1.12, and Paper 26.2 is far +# past that. The same fetch-then-grep shape (and --retry rationale) as +# papermc_latest_jar; the metadata endpoint hands back every platform's URL at once, so +# the grep has to pin the /bukkit/ path segment or it would just as happily return the +# Fabric or Velocity jar, neither of which Paper can load. +luckperms_latest_jar() { + local json url + json="$(curl -fsSL --retry 5 --retry-delay 2 \ + -A "felis-bootstrap (+https://github.com/MliroLirrorsIngenuity/Felis)" \ + "https://metadata.luckperms.net/data/all")" || return 1 + url="$(printf '%s' "$json" \ + | grep -o 'https://download\.luckperms\.net/[0-9]\{1,\}/bukkit/loader/[^"]*\.jar' || true)" + url="${url%%$'\n'*}" + [ -n "$url" ] || return 1 + printf '%s\n' "$url" } # papermc_latest_jar prints the download URL of the newest build of . @@ -1212,9 +1236,10 @@ build_game_stack() { --build-arg LIMBO_VERSION="$LIMBO_VERSION" \ -t "$FELIS_LIMBO_IMAGE" "$GAME_STACK_DIR" - log "building ${FELIS_LOBBY_IMAGE} (Paper ${MC_VERSION} + felis-paper /menu)" + log "building ${FELIS_LOBBY_IMAGE} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ -t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR" local img diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index 74cc56e..2216883 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -1,4 +1,4 @@ -# Felis lobby image: Paper + the felis-paper /menu plugin. +# Felis lobby image: Paper + the felis-paper /menu plugin + LuckPerms. # # CODE-ONLY in this repo — not built by the Go CI. It packages the always-on # "lobby" hub the setup provisioner points [velocity] lobby_image at. The lobby is @@ -9,6 +9,8 @@ # Fill v3 API — api.papermc.io v2 has returned HTTP 410 since 2026-07-01): # docker build -f deploy/lobby/Dockerfile \ # --build-arg PAPER_JAR_URL=https://fill-data.papermc.io/v1/objects//paper-26.2-.jar \ +# --build-arg LUCKPERMS_JAR_URL="$(curl -fsSL https://metadata.luckperms.net/data/all \ +# | grep -o 'https://download.luckperms.net/[^"]*/bukkit/loader/[^"]*\.jar')" \ # -t felis-lobby:demo . # docker save felis-lobby:demo | sudo k3s ctr images import - # # felis.toml → [velocity] lobby_image = "felis-lobby:demo" @@ -40,15 +42,27 @@ RUN cd plugins/paper \ # also runs the plugin's Java-21 bytecode, so only the runtime moves. FROM eclipse-temurin:25-jre ARG PAPER_JAR_URL +# LuckPerms is required, not optional: the panel's whole permission surface +# (internal/api/handlers_access.go) issues `lp user ...` over RCON, so a lobby built +# without it answers every grant with "Unknown command" — a failure the operator only +# discovers in production, because the server itself starts and runs perfectly well. +# Failing the build is the cheap place to notice. Resolved by URL rather than pinned +# here for the same reason PAPER_JAR_URL is: bootstrap.sh asks upstream for the current +# build, so this file does not go stale on every LuckPerms release. +ARG LUCKPERMS_JAR_URL WORKDIR /paper RUN set -eu; \ if [ -z "${PAPER_JAR_URL:-}" ]; then \ echo "ERROR: --build-arg PAPER_JAR_URL= is required" >&2; exit 1; \ fi; \ + if [ -z "${LUCKPERMS_JAR_URL:-}" ]; then \ + echo "ERROR: --build-arg LUCKPERMS_JAR_URL= is required" >&2; exit 1; \ + fi; \ apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \ - curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ - apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ mkdir -p /paper/plugins; \ + curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ + curl -fSL "$LUCKPERMS_JAR_URL" -o /paper/plugins/LuckPerms.jar; \ + apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ echo "eula=true" > /paper/eula.txt COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar # The entrypoint writes the Velocity modern-forwarding config (and REFUSES to start diff --git a/deploy/lobby/entrypoint.sh b/deploy/lobby/entrypoint.sh index 0641ae4..230e6fb 100644 --- a/deploy/lobby/entrypoint.sh +++ b/deploy/lobby/entrypoint.sh @@ -43,6 +43,10 @@ fi mkdir -p "$DATA_DIR/plugins" cp -f "$RUNTIME_DIR/paper.jar" "$DATA_DIR/paper.jar" cp -f "$RUNTIME_DIR/plugins/felis-paper.jar" "$DATA_DIR/plugins/felis-paper.jar" +# Only the jar is refreshed — LuckPerms keeps its H2 database and config under +# $DATA_DIR/plugins/LuckPerms/, which is exactly the state the PVC exists to preserve. +# Every grant the panel has ever issued lives there, so this must never be a wipe. +cp -f "$RUNTIME_DIR/plugins/LuckPerms.jar" "$DATA_DIR/plugins/LuckPerms.jar" printf 'eula=true\n' > "$DATA_DIR/eula.txt" cd "$DATA_DIR"