Loading .github/workflows/ci.yml +4 −6 Changes for .github/workflows/ci.yml: 4 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -203,17 +203,15 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # The other jobs never touch the Java layer: the plugin jars were only ever # compiled by bootstrap on a live host, and the three test mains under # plugins/*/test were run by hand. JDK 21 plus the Gradle major the plugin # Dockerfiles pin (8.14) is that same toolchain, in CI. # compiled by bootstrap on a live host, and the test mains under plugins/*/test # were run by hand. JDK 25 is what the plugin build image runs (paper-api 26.x # needs it); each module's wrapper brings the Gradle the image pins. - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: '21' java-version: '25' - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 with: gradle-version: '8.14' - run: bash plugins/test.sh Loading bootstrap_asset.go +6 −4 Changes for bootstrap_asset.go: 6 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -22,15 +22,17 @@ var bootstrapAssets embed.FS // developer's working tree carries gradle output (plugins/*/build, plugins/*/bin, // and for the modded loaders a decompiled Minecraft under build/) which would // otherwise be baked into every felis binary. Keep them explicit — add a source // directory here, never a parent. // directory here, never a parent. Each module's gradle/verification-metadata.xml rides // along, since Gradle builds unverified without it; the wrapper stays out, because the // image builds run the pinned build image's own gradle. // //go:embed deploy/game-stack.lock //go:embed deploy/limbo/Dockerfile deploy/limbo/entrypoint.sh //go:embed deploy/lobby/Dockerfile deploy/lobby/entrypoint.sh //go:embed deploy/paper/Dockerfile deploy/paper/entrypoint.sh //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml //go:embed plugins/shared/src var gameStackAssets embed.FS Loading bootstrap_asset_test.go +185 −11 Changes for bootstrap_asset_test.go: 185 added lines, 11 removed lines. Original line number Diff line number Diff line package felis import ( "encoding/xml" "io/fs" "os" "regexp" Loading Loading @@ -210,17 +211,7 @@ func requireEmbedded(t *testing.T, path string) { // with a strict KEY=value parser that dies on anything unexpected, so a malformed lock is a // failed install on every host. Check the shipped copy the same way here. func TestGameStackLockIsComplete(t *testing.T) { lock := map[string]string{} for line := range strings.SplitSeq(readGameStackFile(t, "deploy/game-stack.lock"), "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { t.Fatalf("not a KEY=value line: %q", line) } lock[k] = v } lock := gameStackLock(t) m := regexp.MustCompile(`GAME_STACK_LOCK_KEYS="([^"]*)"`).FindStringSubmatch(BootstrapScript()) if m == nil { t.Fatal("bootstrap.sh no longer declares GAME_STACK_LOCK_KEYS") Loading Loading @@ -313,6 +304,189 @@ func TestDockerfileBaseImagesArePinnedByDigest(t *testing.T) { } } // The plugin jars are built in three places the installer controls — the lobby and limbo // image builds and bootstrap's Velocity build — and through each module's wrapper by a // developer or CI. A tag alone is whatever it points at on build day, and two Gradle // versions are two chances for a build to pass in one place and break in the other, so // all of them run one image, pinned by digest, whose Gradle is the wrappers' Gradle. func TestPluginBuildsRunOnePinnedGradle(t *testing.T) { sources := map[string]string{ "deploy/lobby/Dockerfile": readGameStackFile(t, "deploy/lobby/Dockerfile"), "deploy/limbo/Dockerfile": readGameStackFile(t, "deploy/limbo/Dockerfile"), "deploy/bootstrap.sh": BootstrapScript(), } anyRef := regexp.MustCompile(`gradle:[\w.-]+(@sha256:\w+)?`) pinned := regexp.MustCompile(`^gradle:(\d+\.\d+(?:\.\d+)?)-jdk\d+@sha256:[0-9a-f]{64}$`) images := map[string]bool{} gradle := "" for name, body := range sources { refs := anyRef.FindAllString(body, -1) if len(refs) == 0 { t.Errorf("%s names no gradle image", name) } for _, ref := range refs { m := pinned.FindStringSubmatch(ref) if m == nil { t.Errorf("%s: %s is not a gradle image pinned by digest", name, ref) continue } images[ref] = true gradle = m[1] } } if len(images) != 1 { t.Fatalf("the plugin builds use %d different gradle images, want one: %v", len(images), images) } // bootstrap names the image once and has to spend it where it builds the jar. if !strings.Contains(BootstrapScript(), `"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build`) { t.Error("build_velocity_plugin does not build in $PLUGIN_BUILD_IMAGE") } for _, module := range []string{"velocity", "paper", "limbo"} { props := wrapperProperties(t, module) if want := "gradle-" + gradle + "-bin.zip"; !strings.HasSuffix(props["distributionUrl"], "/"+want) { t.Errorf("plugins/%s wrapper runs %s; the image builds run Gradle %s", module, props["distributionUrl"], gradle) } } // The mods are no part of the install, but a wrapper without a checksum runs whatever // the download handed it. for _, module := range []string{"velocity", "paper", "limbo", "fabric", "forge", "neoforge"} { if sum := wrapperProperties(t, module)["distributionSha256Sum"]; !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sum) { t.Errorf("plugins/%s wrapper pins no distribution sha256 (got %q)", module, sum) } } } // Each plugin compiles against the API of the exact build the install runs, and Gradle // checks those bytes against the module's verification file. Nothing but this test ties // the three to deploy/game-stack.lock: a lock refresh that leaves them behind builds the // lobby against yesterday's API, or fails every image build on a checksum the file does // not have. func TestPluginApisAreTheLockedBuilds(t *testing.T) { lock := gameStackLock(t) // Paper: paper-<mc>-<build>.jar runs; paper-api <mc>.build.<build>-<channel> compiles. jar := regexp.MustCompile(`/paper-([^/]+)-(\d+)\.jar$`).FindStringSubmatch(lock["PAPER_JAR_URL"]) if jar == nil { t.Fatalf("PAPER_JAR_URL %s does not name paper-<mc>-<build>.jar", lock["PAPER_JAR_URL"]) } dep := regexp.MustCompile(`compileOnly 'io\.papermc\.paper:paper-api:([^']+)'`). FindStringSubmatch(readGameStackFile(t, "plugins/paper/build.gradle")) if dep == nil { t.Fatal("plugins/paper/build.gradle declares no paper-api dependency") } if !regexp.MustCompile(`^` + regexp.QuoteMeta(jar[1]+".build."+jar[2]) + `(-[a-z]+)?$`).MatchString(dep[1]) { t.Errorf("paper-api %s is not the API of the locked server paper-%s-%s.jar", dep[1], jar[1], jar[2]) } requireVerified(t, "paper", "io.papermc.paper", "paper-api", dep[1]) // Limbo: the lock's release, passed to the image build, which refuses to guess one. limbo := lock["LIMBO_VERSION"] if !strings.Contains(BootstrapScript(), `--build-arg LIMBO_VERSION="$LIMBO_VERSION"`) { t.Error("bootstrap.sh does not pass the locked LIMBO_VERSION to the limbo image build") } dockerfile := readGameStackFile(t, "deploy/limbo/Dockerfile") if !regexp.MustCompile(`(?m)^ARG LIMBO_VERSION$`).MatchString(dockerfile) || !strings.Contains(dockerfile, `if [ -z "${LIMBO_VERSION:-}" ]`) { t.Error("deploy/limbo/Dockerfile does not require LIMBO_VERSION; a build without it would " + "compile against a version nobody chose") } // LOOHP publishes the jar the login gate runs as the Limbo API artifact itself, so the // checksum Gradle holds for it is the lock's: compiled-against and running are one file. if got := requireVerified(t, "limbo", "com.loohp", "Limbo", limbo)["Limbo-"+limbo+".jar"]; got != lock["LIMBO_JAR_SHA256"] { t.Errorf("verification-metadata.xml holds %q for Limbo-%s.jar; the login gate runs %s", got, limbo, lock["LIMBO_JAR_SHA256"]) } // Velocity: the API default is the proxy the install runs. api := regexp.MustCompile(`findProperty\('velocityApi'\) \?: '([^']+)'`). FindStringSubmatch(readGameStackFile(t, "plugins/velocity/build.gradle")) if api == nil { t.Fatal("plugins/velocity/build.gradle has no velocityApi default") } if api[1] != lock["VELOCITY_VERSION"] { t.Errorf("velocity-api defaults to %s; the install runs Velocity %s", api[1], lock["VELOCITY_VERSION"]) } requireVerified(t, "velocity", "com.velocitypowered", "velocity-api", api[1]) } // requireVerified asserts the module's shipped verification file checks metadata and // pins group:name:version, and returns that component's artifact sha256s by file name. func requireVerified(t *testing.T, module, group, name, version string) map[string]string { t.Helper() path := "plugins/" + module + "/gradle/verification-metadata.xml" var doc struct { VerifyMetadata bool `xml:"configuration>verify-metadata"` Components []struct { Group string `xml:"group,attr"` Name string `xml:"name,attr"` Version string `xml:"version,attr"` Artifacts []struct { Name string `xml:"name,attr"` SHA256 []struct { Value string `xml:"value,attr"` } `xml:"sha256"` } `xml:"artifact"` } `xml:"components>component"` } if err := xml.Unmarshal([]byte(readGameStackFile(t, path)), &doc); err != nil { t.Fatalf("%s: %v", path, err) } if !doc.VerifyMetadata { t.Errorf("%s does not verify metadata; a swapped pom could redirect the graph", path) } for _, c := range doc.Components { if c.Group != group || c.Name != name || c.Version != version { continue } sums := map[string]string{} for _, a := range c.Artifacts { if len(a.SHA256) > 0 { sums[a.Name] = a.SHA256[0].Value } } if sums[name+"-"+version+".jar"] == "" { t.Errorf("%s pins %s:%s:%s but no sha256 for its jar", path, group, name, version) } return sums } t.Errorf("%s has no checksum for %s:%s:%s; the build would refuse it", path, group, name, version) return nil } // wrapperProperties reads a module's gradle-wrapper.properties off disk: the wrappers are // for developers and CI, and nothing embeds them. func wrapperProperties(t *testing.T, module string) map[string]string { t.Helper() b, err := os.ReadFile("plugins/" + module + "/gradle/wrapper/gradle-wrapper.properties") if err != nil { t.Fatal(err) } props := map[string]string{} for line := range strings.SplitSeq(string(b), "\n") { if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok && !strings.HasPrefix(k, "#") { props[k] = strings.ReplaceAll(v, `\:`, ":") } } return props } // gameStackLock parses the shipped deploy/game-stack.lock the way bootstrap.sh does. func gameStackLock(t *testing.T) map[string]string { t.Helper() lock := map[string]string{} for line := range strings.SplitSeq(readGameStackFile(t, "deploy/game-stack.lock"), "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { t.Fatalf("not a KEY=value line: %q", line) } lock[k] = v } return lock } func readGameStackFile(t *testing.T, name string) string { t.Helper() b, err := gameStackAssets.ReadFile(name) Loading deploy/bootstrap.sh +6 −2 Changes for deploy/bootstrap.sh: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -372,6 +372,9 @@ SYSTEM_SERVER_IMAGES="${STATE_DIR}/system-server-images" PG_FIREWALL_RULES="${STATE_DIR}/postgres-firewall.nft" PG_FIREWALL_SERVICE="/etc/systemd/system/felis-postgres-firewall.service" JRE_DIR="/opt/felis/jre" # The gradle image the lobby and limbo Dockerfiles build their plugins in, digest # included; build_velocity_plugin runs the same one. PLUGIN_BUILD_IMAGE="gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01" K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}" K3S_BIN="${K3S_BIN_DIR}/k3s" # k3s's containerd mirror config, written by configure_registry_mirror. A variable Loading Loading @@ -2126,7 +2129,8 @@ atomic_install_file() { # build_velocity_plugin compiles plugins/velocity in the same gradle image the two # Dockerfiles use, and drops the jar where Velocity will look for it. Docker is the # toolchain here on purpose: the host needs no JDK and no gradle, only a JRE. # toolchain here on purpose: the host needs no JDK and no gradle, only a JRE. Gradle # checks every dependency against plugins/velocity/gradle/verification-metadata.xml. build_velocity_plugin() { log "building felis-velocity.jar (gradle in a container; the host gets no JDK)" prepare_velocity_layout Loading @@ -2134,7 +2138,7 @@ build_velocity_plugin() { docker run --rm \ -v "${GAME_STACK_DIR}:/src:z" \ -w /src/plugins/velocity \ gradle:8.14-jdk21 gradle --no-daemon clean build \ "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build \ || die "felis-velocity plugin build failed" local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar ) [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \ Loading deploy/limbo/Dockerfile +14 −10 Changes for deploy/limbo/Dockerfile: 14 added lines, 10 removed lines. Original line number Diff line number Diff line Loading @@ -29,22 +29,26 @@ # 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@sha256:5c4c0c4284de4a19951e82ac78f86dbcda2e136644bbfe159beba7ea3420cc80 AS plugin # The same pinned Gradle image as the lobby build (see deploy/lobby/Dockerfile). Its JDK # must be >= 21 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". build.gradle still targets release 17 bytecode so the plugin loads on Java 17+. FROM gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01 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) \ # The Limbo API release to compile against: deploy/game-stack.lock's LIMBO_VERSION, which # bootstrap passes. Required — the API is checked against the checksum # plugins/limbo/gradle/verification-metadata.xml holds for that release. ARG LIMBO_VERSION RUN if [ -z "${LIMBO_VERSION:-}" ]; then \ echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \ fi \ && cd plugins/limbo \ && gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build \ && cp build/libs/*.jar /felis-limbo.jar # ---- assemble the runtime ---- Loading Loading
.github/workflows/ci.yml +4 −6 Changes for .github/workflows/ci.yml: 4 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -203,17 +203,15 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # The other jobs never touch the Java layer: the plugin jars were only ever # compiled by bootstrap on a live host, and the three test mains under # plugins/*/test were run by hand. JDK 21 plus the Gradle major the plugin # Dockerfiles pin (8.14) is that same toolchain, in CI. # compiled by bootstrap on a live host, and the test mains under plugins/*/test # were run by hand. JDK 25 is what the plugin build image runs (paper-api 26.x # needs it); each module's wrapper brings the Gradle the image pins. - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: '21' java-version: '25' - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 with: gradle-version: '8.14' - run: bash plugins/test.sh Loading
bootstrap_asset.go +6 −4 Changes for bootstrap_asset.go: 6 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -22,15 +22,17 @@ var bootstrapAssets embed.FS // developer's working tree carries gradle output (plugins/*/build, plugins/*/bin, // and for the modded loaders a decompiled Minecraft under build/) which would // otherwise be baked into every felis binary. Keep them explicit — add a source // directory here, never a parent. // directory here, never a parent. Each module's gradle/verification-metadata.xml rides // along, since Gradle builds unverified without it; the wrapper stays out, because the // image builds run the pinned build image's own gradle. // //go:embed deploy/game-stack.lock //go:embed deploy/limbo/Dockerfile deploy/limbo/entrypoint.sh //go:embed deploy/lobby/Dockerfile deploy/lobby/entrypoint.sh //go:embed deploy/paper/Dockerfile deploy/paper/entrypoint.sh //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml //go:embed plugins/shared/src var gameStackAssets embed.FS Loading
bootstrap_asset_test.go +185 −11 Changes for bootstrap_asset_test.go: 185 added lines, 11 removed lines. Original line number Diff line number Diff line package felis import ( "encoding/xml" "io/fs" "os" "regexp" Loading Loading @@ -210,17 +211,7 @@ func requireEmbedded(t *testing.T, path string) { // with a strict KEY=value parser that dies on anything unexpected, so a malformed lock is a // failed install on every host. Check the shipped copy the same way here. func TestGameStackLockIsComplete(t *testing.T) { lock := map[string]string{} for line := range strings.SplitSeq(readGameStackFile(t, "deploy/game-stack.lock"), "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { t.Fatalf("not a KEY=value line: %q", line) } lock[k] = v } lock := gameStackLock(t) m := regexp.MustCompile(`GAME_STACK_LOCK_KEYS="([^"]*)"`).FindStringSubmatch(BootstrapScript()) if m == nil { t.Fatal("bootstrap.sh no longer declares GAME_STACK_LOCK_KEYS") Loading Loading @@ -313,6 +304,189 @@ func TestDockerfileBaseImagesArePinnedByDigest(t *testing.T) { } } // The plugin jars are built in three places the installer controls — the lobby and limbo // image builds and bootstrap's Velocity build — and through each module's wrapper by a // developer or CI. A tag alone is whatever it points at on build day, and two Gradle // versions are two chances for a build to pass in one place and break in the other, so // all of them run one image, pinned by digest, whose Gradle is the wrappers' Gradle. func TestPluginBuildsRunOnePinnedGradle(t *testing.T) { sources := map[string]string{ "deploy/lobby/Dockerfile": readGameStackFile(t, "deploy/lobby/Dockerfile"), "deploy/limbo/Dockerfile": readGameStackFile(t, "deploy/limbo/Dockerfile"), "deploy/bootstrap.sh": BootstrapScript(), } anyRef := regexp.MustCompile(`gradle:[\w.-]+(@sha256:\w+)?`) pinned := regexp.MustCompile(`^gradle:(\d+\.\d+(?:\.\d+)?)-jdk\d+@sha256:[0-9a-f]{64}$`) images := map[string]bool{} gradle := "" for name, body := range sources { refs := anyRef.FindAllString(body, -1) if len(refs) == 0 { t.Errorf("%s names no gradle image", name) } for _, ref := range refs { m := pinned.FindStringSubmatch(ref) if m == nil { t.Errorf("%s: %s is not a gradle image pinned by digest", name, ref) continue } images[ref] = true gradle = m[1] } } if len(images) != 1 { t.Fatalf("the plugin builds use %d different gradle images, want one: %v", len(images), images) } // bootstrap names the image once and has to spend it where it builds the jar. if !strings.Contains(BootstrapScript(), `"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build`) { t.Error("build_velocity_plugin does not build in $PLUGIN_BUILD_IMAGE") } for _, module := range []string{"velocity", "paper", "limbo"} { props := wrapperProperties(t, module) if want := "gradle-" + gradle + "-bin.zip"; !strings.HasSuffix(props["distributionUrl"], "/"+want) { t.Errorf("plugins/%s wrapper runs %s; the image builds run Gradle %s", module, props["distributionUrl"], gradle) } } // The mods are no part of the install, but a wrapper without a checksum runs whatever // the download handed it. for _, module := range []string{"velocity", "paper", "limbo", "fabric", "forge", "neoforge"} { if sum := wrapperProperties(t, module)["distributionSha256Sum"]; !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sum) { t.Errorf("plugins/%s wrapper pins no distribution sha256 (got %q)", module, sum) } } } // Each plugin compiles against the API of the exact build the install runs, and Gradle // checks those bytes against the module's verification file. Nothing but this test ties // the three to deploy/game-stack.lock: a lock refresh that leaves them behind builds the // lobby against yesterday's API, or fails every image build on a checksum the file does // not have. func TestPluginApisAreTheLockedBuilds(t *testing.T) { lock := gameStackLock(t) // Paper: paper-<mc>-<build>.jar runs; paper-api <mc>.build.<build>-<channel> compiles. jar := regexp.MustCompile(`/paper-([^/]+)-(\d+)\.jar$`).FindStringSubmatch(lock["PAPER_JAR_URL"]) if jar == nil { t.Fatalf("PAPER_JAR_URL %s does not name paper-<mc>-<build>.jar", lock["PAPER_JAR_URL"]) } dep := regexp.MustCompile(`compileOnly 'io\.papermc\.paper:paper-api:([^']+)'`). FindStringSubmatch(readGameStackFile(t, "plugins/paper/build.gradle")) if dep == nil { t.Fatal("plugins/paper/build.gradle declares no paper-api dependency") } if !regexp.MustCompile(`^` + regexp.QuoteMeta(jar[1]+".build."+jar[2]) + `(-[a-z]+)?$`).MatchString(dep[1]) { t.Errorf("paper-api %s is not the API of the locked server paper-%s-%s.jar", dep[1], jar[1], jar[2]) } requireVerified(t, "paper", "io.papermc.paper", "paper-api", dep[1]) // Limbo: the lock's release, passed to the image build, which refuses to guess one. limbo := lock["LIMBO_VERSION"] if !strings.Contains(BootstrapScript(), `--build-arg LIMBO_VERSION="$LIMBO_VERSION"`) { t.Error("bootstrap.sh does not pass the locked LIMBO_VERSION to the limbo image build") } dockerfile := readGameStackFile(t, "deploy/limbo/Dockerfile") if !regexp.MustCompile(`(?m)^ARG LIMBO_VERSION$`).MatchString(dockerfile) || !strings.Contains(dockerfile, `if [ -z "${LIMBO_VERSION:-}" ]`) { t.Error("deploy/limbo/Dockerfile does not require LIMBO_VERSION; a build without it would " + "compile against a version nobody chose") } // LOOHP publishes the jar the login gate runs as the Limbo API artifact itself, so the // checksum Gradle holds for it is the lock's: compiled-against and running are one file. if got := requireVerified(t, "limbo", "com.loohp", "Limbo", limbo)["Limbo-"+limbo+".jar"]; got != lock["LIMBO_JAR_SHA256"] { t.Errorf("verification-metadata.xml holds %q for Limbo-%s.jar; the login gate runs %s", got, limbo, lock["LIMBO_JAR_SHA256"]) } // Velocity: the API default is the proxy the install runs. api := regexp.MustCompile(`findProperty\('velocityApi'\) \?: '([^']+)'`). FindStringSubmatch(readGameStackFile(t, "plugins/velocity/build.gradle")) if api == nil { t.Fatal("plugins/velocity/build.gradle has no velocityApi default") } if api[1] != lock["VELOCITY_VERSION"] { t.Errorf("velocity-api defaults to %s; the install runs Velocity %s", api[1], lock["VELOCITY_VERSION"]) } requireVerified(t, "velocity", "com.velocitypowered", "velocity-api", api[1]) } // requireVerified asserts the module's shipped verification file checks metadata and // pins group:name:version, and returns that component's artifact sha256s by file name. func requireVerified(t *testing.T, module, group, name, version string) map[string]string { t.Helper() path := "plugins/" + module + "/gradle/verification-metadata.xml" var doc struct { VerifyMetadata bool `xml:"configuration>verify-metadata"` Components []struct { Group string `xml:"group,attr"` Name string `xml:"name,attr"` Version string `xml:"version,attr"` Artifacts []struct { Name string `xml:"name,attr"` SHA256 []struct { Value string `xml:"value,attr"` } `xml:"sha256"` } `xml:"artifact"` } `xml:"components>component"` } if err := xml.Unmarshal([]byte(readGameStackFile(t, path)), &doc); err != nil { t.Fatalf("%s: %v", path, err) } if !doc.VerifyMetadata { t.Errorf("%s does not verify metadata; a swapped pom could redirect the graph", path) } for _, c := range doc.Components { if c.Group != group || c.Name != name || c.Version != version { continue } sums := map[string]string{} for _, a := range c.Artifacts { if len(a.SHA256) > 0 { sums[a.Name] = a.SHA256[0].Value } } if sums[name+"-"+version+".jar"] == "" { t.Errorf("%s pins %s:%s:%s but no sha256 for its jar", path, group, name, version) } return sums } t.Errorf("%s has no checksum for %s:%s:%s; the build would refuse it", path, group, name, version) return nil } // wrapperProperties reads a module's gradle-wrapper.properties off disk: the wrappers are // for developers and CI, and nothing embeds them. func wrapperProperties(t *testing.T, module string) map[string]string { t.Helper() b, err := os.ReadFile("plugins/" + module + "/gradle/wrapper/gradle-wrapper.properties") if err != nil { t.Fatal(err) } props := map[string]string{} for line := range strings.SplitSeq(string(b), "\n") { if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok && !strings.HasPrefix(k, "#") { props[k] = strings.ReplaceAll(v, `\:`, ":") } } return props } // gameStackLock parses the shipped deploy/game-stack.lock the way bootstrap.sh does. func gameStackLock(t *testing.T) map[string]string { t.Helper() lock := map[string]string{} for line := range strings.SplitSeq(readGameStackFile(t, "deploy/game-stack.lock"), "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { t.Fatalf("not a KEY=value line: %q", line) } lock[k] = v } return lock } func readGameStackFile(t *testing.T, name string) string { t.Helper() b, err := gameStackAssets.ReadFile(name) Loading
deploy/bootstrap.sh +6 −2 Changes for deploy/bootstrap.sh: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -372,6 +372,9 @@ SYSTEM_SERVER_IMAGES="${STATE_DIR}/system-server-images" PG_FIREWALL_RULES="${STATE_DIR}/postgres-firewall.nft" PG_FIREWALL_SERVICE="/etc/systemd/system/felis-postgres-firewall.service" JRE_DIR="/opt/felis/jre" # The gradle image the lobby and limbo Dockerfiles build their plugins in, digest # included; build_velocity_plugin runs the same one. PLUGIN_BUILD_IMAGE="gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01" K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}" K3S_BIN="${K3S_BIN_DIR}/k3s" # k3s's containerd mirror config, written by configure_registry_mirror. A variable Loading Loading @@ -2126,7 +2129,8 @@ atomic_install_file() { # build_velocity_plugin compiles plugins/velocity in the same gradle image the two # Dockerfiles use, and drops the jar where Velocity will look for it. Docker is the # toolchain here on purpose: the host needs no JDK and no gradle, only a JRE. # toolchain here on purpose: the host needs no JDK and no gradle, only a JRE. Gradle # checks every dependency against plugins/velocity/gradle/verification-metadata.xml. build_velocity_plugin() { log "building felis-velocity.jar (gradle in a container; the host gets no JDK)" prepare_velocity_layout Loading @@ -2134,7 +2138,7 @@ build_velocity_plugin() { docker run --rm \ -v "${GAME_STACK_DIR}:/src:z" \ -w /src/plugins/velocity \ gradle:8.14-jdk21 gradle --no-daemon clean build \ "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build \ || die "felis-velocity plugin build failed" local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar ) [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \ Loading
deploy/limbo/Dockerfile +14 −10 Changes for deploy/limbo/Dockerfile: 14 added lines, 10 removed lines. Original line number Diff line number Diff line Loading @@ -29,22 +29,26 @@ # 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@sha256:5c4c0c4284de4a19951e82ac78f86dbcda2e136644bbfe159beba7ea3420cc80 AS plugin # The same pinned Gradle image as the lobby build (see deploy/lobby/Dockerfile). Its JDK # must be >= 21 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". build.gradle still targets release 17 bytecode so the plugin loads on Java 17+. FROM gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01 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) \ # The Limbo API release to compile against: deploy/game-stack.lock's LIMBO_VERSION, which # bootstrap passes. Required — the API is checked against the checksum # plugins/limbo/gradle/verification-metadata.xml holds for that release. ARG LIMBO_VERSION RUN if [ -z "${LIMBO_VERSION:-}" ]; then \ echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \ fi \ && cd plugins/limbo \ && gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build \ && cp build/libs/*.jar /felis-limbo.jar # ---- assemble the runtime ---- Loading