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

feat(bootstrap): game stack 按 lock 文件固定构建并校验 sha256,基础镜像按 digest 固定,JRE 固定补丁版本

parent aace66e8
Loading
Loading
Loading
Loading
+17 −2
Changes for .github/dependabot.yml: 17 added lines, 2 removed lines.
Original line number Diff line number Diff line
# The workflows pin every action to a commit SHA. This keeps those pins moving: Dependabot
# reads the "# vX.Y.Z" comment next to each SHA and opens a PR that bumps both together.
# The workflows pin every action to a commit SHA, and every Dockerfile pins its base images
# by digest. This keeps those pins moving: Dependabot reads the "# vX.Y.Z" comment next to
# each action SHA, and the tag in front of each image digest, and opens a PR that bumps both
# together.
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
  - package-ecosystem: docker
    directories:
      - /
      - /deploy/limbo
      - /deploy/lobby
      - /deploy/paper
    schedule:
      interval: weekly
    # A new major is a runtime change (Paper 26.x needs Java 25, Limbo's jar is Java 21
    # bytecode), so only digests and minors are proposed; majors move by hand.
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]
+7 −3
Changes for Dockerfile: 7 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -21,14 +21,18 @@
# minutes. The FINAL stage is deliberately NOT pinned — it must stay on the target platform
# or the published arm64 image would carry amd64 layers. It contains only COPY, which
# BuildKit performs itself, so it needs no QEMU either; adding a RUN there would.
FROM --platform=$BUILDPLATFORM node:22-bookworm AS panel
#
# Every base image here and in deploy/{limbo,lobby,paper} is pinned by digest, so a rebuild
# of one release uses the same bytes; .github/dependabot.yml proposes the bumps (tag and
# digest together).
FROM --platform=$BUILDPLATFORM node:22-bookworm@sha256:363e1587494626837fa7f9a23bdb453d13b0ff3c67c705c2805cfc69c2d2fad7 AS panel
WORKDIR /panel
COPY panel/package*.json ./
RUN npm ci
COPY panel/ ./
RUN npm run build

FROM --platform=$BUILDPLATFORM golang:1.26 AS build
FROM --platform=$BUILDPLATFORM golang:1.26@sha256:6c2a5538f964f1c82f97ad14988bf05de100d922d159d0e398b54c7b0ca0c6c9 AS build
WORKDIR /src
ARG TARGETOS=linux
ARG TARGETARCH
@@ -55,7 +59,7 @@ ARG FELIS_VERSION=dev
RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \
    go build -trimpath -ldflags="-s -w -X main.version=${FELIS_VERSION}" -o /out/felis ./cmd/felis

FROM gcr.io/distroless/static-debian12:nonroot
FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
ENV PATH=/usr/local/bin:/usr/bin:/bin
COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis
# distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins
+1 −0
Changes for bootstrap_asset.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ var bootstrapAssets embed.FS
// otherwise be baked into every felis binary. Keep them explicit — add a source
// directory here, never a parent.
//
//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
+108 −0
Changes for bootstrap_asset_test.go: 108 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ package felis

import (
	"io/fs"
	"os"
	"regexp"
	"strings"
	"testing"
@@ -205,6 +206,113 @@ func requireEmbedded(t *testing.T, path string) {
	}
}

// The lock file is the install's only source of upstream builds, and bootstrap.sh reads it
// 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
	}
	m := regexp.MustCompile(`GAME_STACK_LOCK_KEYS="([^"]*)"`).FindStringSubmatch(BootstrapScript())
	if m == nil {
		t.Fatal("bootstrap.sh no longer declares GAME_STACK_LOCK_KEYS")
	}
	keys := strings.Fields(m[1])
	sha := regexp.MustCompile(`^[0-9a-f]{64}$`)
	for _, k := range keys {
		v, ok := lock[k]
		if !ok || v == "" {
			t.Errorf("game-stack.lock does not set %s", k)
			continue
		}
		if strings.HasSuffix(k, "_SHA256") && !sha.MatchString(v) {
			t.Errorf("%s=%q is not a lowercase sha256", k, v)
		}
		// A moving URL pins nothing: the digest check would start failing the day
		// upstream publishes the next build.
		if strings.HasSuffix(k, "_URL") && strings.Contains(v, "lastSuccessfulBuild") {
			t.Errorf("%s names a moving build: %s", k, v)
		}
	}
	for k := range lock {
		if !strings.Contains(" "+m[1]+" ", " "+k+" ") {
			t.Errorf("game-stack.lock sets %s, which bootstrap.sh refuses as an unknown key", k)
		}
	}
	// Fill's URLs are content-addressed; a lock whose digest disagrees with its own URL
	// was edited by hand and half-way.
	for _, name := range []string{"PAPER", "VELOCITY"} {
		if !strings.Contains(lock[name+"_JAR_URL"], "/objects/"+lock[name+"_JAR_SHA256"]+"/") {
			t.Errorf("%s_JAR_SHA256 is not the digest in %s_JAR_URL", name, name)
		}
	}
	if !strings.Contains(lock["LIMBO_JAR_URL"], "-"+lock["MC_VERSION"]+".jar") {
		t.Errorf("LIMBO_JAR_URL %s is not a Minecraft %s build", lock["LIMBO_JAR_URL"], lock["MC_VERSION"])
	}
	if !strings.Contains(lock["PAPER_JAR_URL"], "/paper-"+lock["MC_VERSION"]+"-") {
		t.Errorf("PAPER_JAR_URL %s is not a Minecraft %s build; the lobby would not speak the login gate's protocol", lock["PAPER_JAR_URL"], lock["MC_VERSION"])
	}
}

// Each downloaded jar's digest is a build-arg bootstrap.sh passes and the Dockerfile must
// both require and spend on the file it downloaded; docker only warns about an unknown
// --build-arg, so a renamed arg would ship an unchecked jar.
func TestGameStackDigestsReachTheImageBuilds(t *testing.T) {
	script := BootstrapScript()
	for _, c := range []struct{ dockerfile, arg, path string }{
		{"deploy/limbo/Dockerfile", "LIMBO_JAR_SHA256", "/limbo/Limbo.jar"},
		{"deploy/limbo/Dockerfile", "LIMBO_SCHEM_SHA256", "/limbo/spawn.schem"},
		{"deploy/lobby/Dockerfile", "LUCKPERMS_JAR_SHA256", "/paper/plugins/LuckPerms.jar"},
	} {
		if !strings.Contains(script, "--build-arg "+c.arg+"=\"$"+c.arg+"\"") {
			t.Errorf("bootstrap.sh never passes --build-arg %s", c.arg)
		}
		dockerfile := readGameStackFile(t, c.dockerfile)
		if !strings.Contains(dockerfile, "ARG "+c.arg) {
			t.Errorf("%s declares no ARG %s", c.dockerfile, c.arg)
		}
		if !strings.Contains(dockerfile, `echo "$`+c.arg+`  `+c.path+`" | sha256sum -c`) {
			t.Errorf("%s never verifies %s against %s", c.dockerfile, c.path, c.arg)
		}
	}
}

// A base image named by tag alone is whatever the tag points at on build day.
func TestDockerfileBaseImagesArePinnedByDigest(t *testing.T) {
	root, err := os.ReadFile("Dockerfile")
	if err != nil {
		t.Fatal(err)
	}
	files := map[string]string{"Dockerfile": string(root)}
	for _, name := range []string{"deploy/limbo/Dockerfile", "deploy/lobby/Dockerfile", "deploy/paper/Dockerfile"} {
		files[name] = readGameStackFile(t, name)
	}
	pinned := regexp.MustCompile(`^FROM (--platform=\S+ )?\S+:\S+@sha256:[0-9a-f]{64}( AS \S+)?$`)
	for name, body := range files {
		n := 0
		for line := range strings.SplitSeq(body, "\n") {
			if !strings.HasPrefix(line, "FROM ") {
				continue
			}
			n++
			if !pinned.MatchString(line) {
				t.Errorf("%s: %q is not pinned by digest", name, line)
			}
		}
		if n == 0 {
			t.Errorf("%s has no FROM line", name)
		}
	}
}

func readGameStackFile(t *testing.T, name string) string {
	t.Helper()
	b, err := gameStackAssets.ReadFile(name)
+1 −1
Changes for cmd/felis/update.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -72,7 +72,7 @@ var updateTargets = []updateTarget{
	{
		selector:  "velocity",
		component: "velocity",
		note:      "re-runs install_velocity: newest BUILD of the pinned minor (FELIS_VELOCITY_VERSION), atomic jar install, then restarts felis-velocity",
		note:      "re-runs install_velocity: the build the release pins in deploy/game-stack.lock (FELIS_VELOCITY_VERSION=<minor> takes that minor's newest build instead), sha256-checked, atomic jar install, then restarts felis-velocity only if the jar or its config changed",
		command:   installerRerun,
	},
	{
Loading