Unverified Commit d9453a64 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

build(plugins): 插件构建统一钉到 gradle 9.8 镜像与带 sha256 的 wrapper,paper-api/Limbo 对齐锁文件并启用依赖校验

parent 2d1592bf
Loading
Loading
Loading
Loading
+4 −6
Changes for .github/workflows/ci.yml: 4 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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

+6 −4
Changes for bootstrap_asset.go: 6 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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

+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"
@@ -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")
@@ -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)
+6 −2
Changes for deploy/bootstrap.sh: 6 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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]}" ] \
+14 −10
Changes for deploy/limbo/Dockerfile: 14 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -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