Unverified Commit c7315e44 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(deploy): login-limbo and lobby images with game-port pinning

deploy/limbo assembles LOOHP/Limbo from its loose CI artifacts plus the felis-limbo plugin (and the shared link core), with an entrypoint that pins server-port to the operator's GamePort (25565) on every start. deploy/lobby carries the Paper + felis-paper hub image. .dockerignore re-includes plugins/limbo and plugins/shared so the plugin image build sees them.
parent 241fe21f
Loading
Loading
Loading
Loading
+12 −1
Changes for .dockerignore: 12 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -7,7 +7,18 @@
.git
.claude
**/node_modules
plugins
# plugins/ is code-only and excluded from the MAIN felis image — EXCEPT the two
# source trees the login-limbo image (deploy/limbo/Dockerfile) compiles: its own
# module (plugins/limbo) and the shared link core it srcDir-includes (plugins/shared).
# `plugins/*` excludes each sibling module individually (so plugins/ itself is still
# walked and the negations below can re-include), then the two build trees are pulled
# back in and their Gradle outputs re-excluded to keep the context lean. The main
# image copies none of plugins/, so carrying these two small source dirs is harmless.
plugins/*
!plugins/limbo
!plugins/shared
plugins/**/build
plugins/**/.gradle
docs
*.md
Dockerfile
+86 −0
Changes for deploy/limbo/Dockerfile: 86 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Felis login-limbo image: LOOHP/Limbo + the felis-limbo readiness plugin.
#
# CODE-ONLY in this repo — it is not built by the Go CI. It packages the always-on
# "login" auth gate the setup provisioner points [velocity] login_image at.
#
# LOOHP/Limbo has no official image and no release zip. Its CI
# (ci.loohpjames.com/job/Limbo) publishes two LOOSE artifacts per build:
#   - target/Limbo-<ver>.jar   (the server jar; Main-Class com.loohp.limbo.Limbo)
#   - spawn.schem              (the default spawn schematic)
# There is no bundled server.properties — Limbo writes a default on first run.
# So the runtime is assembled from those two URLs (not a zip) via --build-arg:
#
#   docker build -f deploy/limbo/Dockerfile \
#     --build-arg LIMBO_JAR_URL=https://ci.loohpjames.com/job/Limbo/<n>/artifact/target/Limbo-<ver>.jar \
#     --build-arg LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo/<n>/artifact/spawn.schem \
#     --build-arg LIMBO_VERSION=<maven-api-version> \
#     -t felis-limbo:demo .
#
# Note LIMBO_VERSION (the maven API version the plugin compiles against, e.g.
# 2026.0.2-ALPHA) differs from the jar's CI build-qualified filename (e.g.
# Limbo-2026.0.2-ALPHA-26.2.jar): the CI qualifier is not published to the maven
# repo. Then import it the same way as the control-plane image (k3s ctr import)
# and set [velocity] login_image = "felis-limbo:demo" in felis.toml before setup.
#
# Two contracts the operator depends on:
#   1. The game server listens on 25565 (the CRD's GamePort).
#   2. The felis-limbo readiness endpoint listens on 8080 (StartupSpec.HealthHTTPPort,
#      overridable via FELIS_HEALTH_PORT) and returns 200 only after the first tick.

# ---- build the felis-limbo plugin jar ----
# gradle:*-jdk21 — an official Gradle image on JDK 21. JDK 21 is required because
# current LOOHP/Limbo releases ship Java 21 API classes (class-file major 65); a
# JDK 17 fails to read them with "wrong version 65.0, should be 61.0". The image
# also provides the `gradle` binary (this tree vendors no Gradle wrapper).
# build.gradle still targets release 17 bytecode so the plugin loads on Java 17+.
FROM gradle:8.14-jdk21 AS plugin
WORKDIR /src
# Copy what the limbo module needs: its own tree plus the shared link core it
# srcDir-includes (../shared/src/main/java → /src/plugins/shared/src/main/java), so
# the account-link client + config loader compile straight into the jar.
COPY plugins/limbo/ ./plugins/limbo/
COPY plugins/shared/ ./plugins/shared/
ARG LIMBO_VERSION=+
RUN cd plugins/limbo \
    && (test -x ./gradlew && ./gradlew --no-daemon -PlimboVersion="$LIMBO_VERSION" build \
        || gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build) \
    && cp build/libs/*.jar /felis-limbo.jar

# ---- assemble the runtime ----
# 21-jre: the Limbo jar is Java 21 bytecode (class-file major 65), so a Java 17
# JRE cannot run it (UnsupportedClassVersionError). A 21 JRE also runs the
# plugin's release-17 bytecode fine.
FROM eclipse-temurin:21-jre
ARG LIMBO_JAR_URL
ARG LIMBO_SCHEM_URL
WORKDIR /limbo
# Pull the two loose LOOHP/Limbo CI artifacts: the server jar (required, saved as
# Limbo.jar) and the default spawn schematic (optional). Fail loudly if the jar
# URL was not supplied.
RUN set -eu; \
    if [ -z "${LIMBO_JAR_URL:-}" ]; then \
      echo "ERROR: --build-arg LIMBO_JAR_URL=<Limbo server jar> is required" >&2; exit 1; \
    fi; \
    apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \
    curl -fSL "$LIMBO_JAR_URL" -o /limbo/Limbo.jar; \
    if [ -n "${LIMBO_SCHEM_URL:-}" ]; then \
      curl -fSL "$LIMBO_SCHEM_URL" -o /limbo/spawn.schem; \
    fi; \
    apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
    mkdir -p /limbo/plugins
# Drop the login+readiness plugin in beside Limbo.jar.
COPY --from=plugin /felis-limbo.jar /limbo/plugins/felis-limbo.jar
# The entrypoint pins the game port to the operator's GamePort before launching Limbo.
COPY deploy/limbo/entrypoint.sh /usr/local/bin/felis-entrypoint.sh

ENV FELIS_HEALTH_PORT=8080
# FELIS_GAME_PORT is the port the entrypoint pins Limbo to; it MUST equal the operator's
# GamePort (internal/operator/builders.go). Default 25565 — override only in lockstep
# with the operator.
ENV FELIS_GAME_PORT=25565
EXPOSE 25565 8080
# felis-entrypoint.sh pins server-port then execs `java -jar Limbo.jar --nogui` (headless:
# the pod has no console). Limbo writes the rest of server.properties on first run and
# loads ./spawn.schem as the spawn world. Invoked via `sh` so no +x bit is needed from the
# (Windows) build host.
ENTRYPOINT ["/bin/sh", "/usr/local/bin/felis-entrypoint.sh"]

deploy/limbo/README.md

0 → 100644
+142 −0
Changes for deploy/limbo/README.md: 142 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Login-limbo image (LOOHP/Limbo + felis-limbo)

The always-on **login** auth gate. `felis setup` provisions it as a system
service (`DesiredState=Running`, reaper-exempt) when `[velocity] login_image` is
set in `felis.toml`. Every fresh connection lands here first; it is the only safe
fallback (a stopped/starting backend routes here, never past authentication).

> **Code-only.** This image is not built by the Go CI. It compiles the
> `plugins/limbo` plugin (plus the shared `plugins/shared` link core it
> srcDir-includes) and bundles it with a LOOHP/Limbo release.

## What the plugin does

`felis-limbo` does two jobs — readiness and the in-game login flow.

### Readiness

LOOHP/Limbo has no RCON, so Felis cannot use its usual RCON readiness probe
(spec §5). A bare TCP check would report "ready" the instant the socket binds.
The plugin instead serves an HTTP readiness endpoint that flips to `200` only
after the first server tick — i.e. once the limbo has genuinely started. The
`login` MinecraftServer sets `spec.startup.healthHTTPPort: 8080`, so the pod's
HTTP readinessProbe (and, with RCON disabled, the operator's Ready gate) follows
that true signal.

- Endpoint: `GET /healthz` on `:8080` (override with `FELIS_HEALTH_PORT`).
- `503 starting` before the first tick, `200 ok` after.
- Fail-closed: if the endpoint cannot bind, readiness never turns green and the
  operator keeps the gate in `Starting` — it never advertises an unstarted gate.

### In-game login (spec §B3)

A player reaching the limbo has been UUID-verified upstream (Velocity online-mode)
but is not yet linked to a web account. On join, off the tick thread, the plugin:

1. checks the username-collision **blacklist** and disconnects a barred squatter
   UUID (the genuine Mojang player — different UUID — passes);
2. mints a one-time **Bind Code** for the verified UUID via the felis-api internal
   face;
3. opens a **book** with a clickable link to `console.<root_domain>` plus a chat
   line carrying the code, and tells the player to finish in their **system**
   browser — never the WeChat/QQ in-app browser, where passkey/WebAuthn does not
   work (the web entry additionally guards this; see `internal/panel`);
4. **polls** `link/status/{uuid}` until the player redeems the code on the web
   console, then **transfers** them to the lobby via a BungeeCord `Connect` plugin
   message on `bungeecord:main`;
5. **disconnects (fail-closed)** on blacklist, on a mint/transport failure, or when
   the login window elapses — "rather refuse than admit unauthenticated".

Configuration (deployment inputs, never compiled in; env wins over a
`felis-link.properties` template written in the plugin data dir on first run):

| Env | Meaning | Default |
| --- | ------- | ------- |
| `FELIS_API_BASE_URL`        | felis-api **internal** face base URL | *(required for login)* |
| `FELIS_SERVICE_TOKEN`       | internal service token (secret)      | *(required for login)* |
| `FELIS_ROOT_DOMAIN`         | deployment zone, builds `https://console.<zone>` | *(required for login)* |
| `FELIS_LOBBY_SERVER`        | Velocity server name to transfer to  | `lobby` |
| `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600)     | `300` |
| `FELIS_HEALTH_PORT`         | readiness port                        | `8080` |

If the API config **or** the root domain is absent the login flow stays **OFF** and
the plugin runs readiness-only (the same "load un-crippled" fail-safe the other
Felis plugins use), so a bare image still boots — production must supply the config
for the gate to authenticate. Transfer requires Velocity to accept the BungeeCord
plugin-message channel (`bungee-plugin-message-channel` on the proxy).

## Build

LOOHP/Limbo has no official image and no release zip. Its CI
(`ci.loohpjames.com/job/Limbo`) publishes two **loose** artifacts per build —
`target/Limbo-<ver>.jar` and `spawn.schem` — so the image is assembled from those
two URLs (there is no bundled `server.properties`; Limbo writes a default on first
run):

```
docker build -f deploy/limbo/Dockerfile \
  --build-arg LIMBO_JAR_URL=https://ci.loohpjames.com/job/Limbo/<n>/artifact/target/Limbo-<ver>.jar \
  --build-arg LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo/<n>/artifact/spawn.schem \
  --build-arg LIMBO_VERSION=<maven-api-version> \
  -t felis-limbo:demo .
```

- `LIMBO_JAR_URL` (required) — the server jar; it is saved as `Limbo.jar`.
- `LIMBO_SCHEM_URL` (optional) — the default spawn schematic, saved as
  `spawn.schem` and loaded as the spawn world.
- `LIMBO_VERSION` — the Limbo **maven** API version the plugin compiles against
  (Gradle `-PlimboVersion`). This differs from the jar's CI build-qualified
  filename: e.g. the jar `Limbo-2026.0.2-ALPHA-26.2.jar` corresponds to maven
  version `2026.0.2-ALPHA` (the `-26.2` CI qualifier is not published to the
  maven repo).

Import into k3s and point config at it:

```
docker save felis-limbo:demo | sudo k3s ctr images import -
# felis.toml → [velocity] login_image = "felis-limbo:demo"
sudo felis setup
```

## Ports (handled for you)

The entrypoint (`deploy/limbo/entrypoint.sh`) pins Limbo's `server-port` to
`FELIS_GAME_PORT` (default **25565**, the operator's `GamePort`) on every start —
LOOHP/Limbo would otherwise default to `30000`, unreachable through the Velocity
`NetworkPolicy` / Service / probe the operator drives off that one const. It is
idempotent, so a persisted world volume keeps all its other `server.properties`
settings. Do **not** override `FELIS_GAME_PORT` except in lockstep with the operator.

## Configure (deployer's responsibility)

One setting this image does **not** guess (it keeps the release's own default):

- **Player forwarding** — align Limbo's forwarding with the off-cluster Velocity
  proxy so authenticated players hand off cleanly, and enable the BungeeCord
  plugin-message channel on the proxy so the login gate's `Connect` transfer to the
  lobby lands.

The login flow's own inputs (`FELIS_API_BASE_URL`, `FELIS_ROOT_DOMAIN`,
`FELIS_LOBBY_SERVER`, `FELIS_SERVICE_TOKEN`; see
**[In-game login](#in-game-login-specb3)** above) are wired in for you — you do not
set them by hand:

- The three **non-secret** vars are baked into the `login` MinecraftServer's
  `spec.env` by `felis setup` (`cmd/felis` derives the internal API URL from the
  control namespace — the platform default `felis`; a renamed control namespace must
  be reflected by hand — and the root domain from `felis.toml`).
- `FELIS_SERVICE_TOKEN` is a **secret**, so it is never written into the CRD. `felis
  setup` replicates the `felis-service-token` Secret from the control namespace into
  the minecraft namespace, and the operator injects it into the `login` pod (only)
  via a `secretKeyRef`, keyed off the reserved `login` name. Until the token is
  present the plugin fail-safes to readiness-only, so the gate is never broken — it
  simply does not authenticate yet.
- **NetworkPolicy:** none is required today — neither the minecraft-namespace egress
  nor the control-namespace ingress is policy-locked, so the login pod's call to the
  API internal port is already reachable. If a future deployment adds a minecraft
  egress lock or a control-namespace ingress fence, it must also open the
  login-pod → felis-api internal-port (8081) path.

The Velocity default-landing and waiting-park wiring is printed by `felis setup`
and enforces the invariant: fresh connections hit `login` first; nothing falls
back to the lobby.
+31 −0
Changes for deploy/limbo/entrypoint.sh: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
#!/bin/sh
# Felis login-limbo entrypoint.
#
# Pin Limbo's game port to the pod-facing port the operator contract uses. LOOHP/Limbo
# defaults server-port to 30000, but the Felis operator drives everything — the Service
# Port/TargetPort, the TCP/HTTP readiness probe, the container port and the Velocity
# NetworkPolicy — off a single GamePort const (25565). A backend that bound 30000 would
# be unreachable through that fence. Limbo writes a full server.properties on first run
# and merges any partial we leave in place, so seeding/patching just server-port here is
# enough; the spawn schematic still loads from ./spawn.schem.
#
# Idempotent by design: it runs on every start and rewrites only the server-port line,
# so a persisted world volume that already carries a server.properties keeps all its
# other settings.
set -eu

PORT="${FELIS_GAME_PORT:-25565}"
PROPS="server.properties"

if [ -f "$PROPS" ]; then
  if grep -q '^server-port=' "$PROPS"; then
    sed -i "s/^server-port=.*/server-port=${PORT}/" "$PROPS"
  else
    printf 'server-port=%s\n' "$PORT" >> "$PROPS"
  fi
else
  printf 'server-port=%s\n' "$PORT" > "$PROPS"
fi

echo "felis-limbo: pinned server-port=${PORT} (operator GamePort)"
exec java -jar Limbo.jar --nogui "$@"
+47 −0
Changes for deploy/lobby/Dockerfile: 47 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Felis lobby image: Paper + the felis-paper /menu plugin.
#
# 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
# the POST-auth /menu hub: it is reached only when the login gate transfers an
# authenticated player onward, and it must never be a fallback target.
#
# Build:
#   docker build -f deploy/lobby/Dockerfile \
#     --build-arg PAPER_JAR_URL=https://<mirror>/paper-1.21.x-<build>.jar \
#     -t felis-lobby:demo .
#   docker save felis-lobby:demo | sudo k3s ctr images import -
#   # felis.toml → [velocity] lobby_image = "felis-lobby:demo"
#
# Contract: the game server listens on 25565 (the CRD GamePort). The lobby speaks
# only the felis:control plugin-message channel (spec §12) — it holds no felis-api
# token.

# ---- build the felis-paper plugin jar (Paper API is Java 21) ----
FROM eclipse-temurin:21-jdk AS plugin
WORKDIR /src
COPY plugins/paper/ ./plugins/paper/
COPY plugins/shared/ ./plugins/shared/
RUN cd plugins/paper \
    && (test -x ./gradlew && ./gradlew --no-daemon build \
        || gradle --no-daemon build) \
    && cp build/libs/*.jar /felis-paper.jar

# ---- assemble the runtime ----
FROM eclipse-temurin:21-jre
ARG PAPER_JAR_URL
WORKDIR /paper
RUN set -eu; \
    if [ -z "${PAPER_JAR_URL:-}" ]; then \
      echo "ERROR: --build-arg PAPER_JAR_URL=<paper jar> 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; \
    echo "eula=true" > /paper/eula.txt
COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar

EXPOSE 25565
# nogui headless; the first boot generates server.properties (align online-mode /
# forwarding with the Velocity proxy afterwards — see README).
ENTRYPOINT ["java", "-jar", "paper.jar", "--nogui"]
Loading