From 1141ebcd49bf5571a6cc0c584e1ae69c5a19743b Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 00:35:09 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E5=8A=A0=20-race=E3=80=81staticcheck?= =?UTF-8?q?=E3=80=81govulncheck=E3=80=81shellcheck=20=E4=B8=8E=E7=9C=9F=20?= =?UTF-8?q?PostgreSQL=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E9=97=A8?= =?UTF-8?q?=E7=A6=81=EF=BC=8Crelease=20=E5=A4=8D=E7=94=A8=20ci=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 70 +++++++++++++++++++++++++++++++- .github/workflows/release.yml | 48 ++++++---------------- cmd/felis/fetchcontext.go | 2 +- deploy/bootstrap.sh | 15 +++---- deploy/bootstrap_test.sh | 34 +++++++++++++--- deploy/update-game-stack-lock.sh | 3 ++ internal/config/config.go | 2 +- internal/registrygate/gate.go | 14 ++++--- 8 files changed, 130 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59580bf..a8c0d9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,14 @@ # PR is what asks for the answer. name: ci +# +# release.yml calls this workflow (workflow_call) before it builds anything, so a tag passes +# exactly these gates and there is one list of them. on: push: branches: [main] pull_request: + workflow_call: permissions: contents: read @@ -43,7 +47,61 @@ jobs: echo "gofmt needed on:"; echo "$unformatted"; exit 1 fi - run: go vet ./... - - run: go test ./... + # -race: felis-api and the operator are mostly goroutines (watchers, the + # registry pruner, the backup scheduler, the rate limiters). + - run: go test -race ./... + # The version is pinned here and bumped by hand; Dependabot does not read `go run`. + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./... + + # Separate from the go job so a newly published advisory reads as what it is. govulncheck + # exits non-zero only for vulnerable code this module can actually reach, standard + # library included: setup-go installs the newest patch of go.mod's Go line, so a finding + # there means the Dockerfile's golang digest (which ships the release) needs a bump too. + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + + # The business stores' SQL against a real PostgreSQL (internal/pgint): the unit suites run + # on fakes, and PGRepo drifted from them three times while those stayed green. 13 is the + # oldest server a supported distribution installs (EL9), 18 the newest (Arch). + pgint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + postgres: ['13', '18'] + services: + postgres: + image: postgres:${{ matrix.postgres }} + env: + POSTGRES_USER: felis + POSTGRES_PASSWORD: pgint + POSTGRES_DB: felis_pgint + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U felis -d felis_pgint" + --health-interval 2s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - run: go test -race -tags pgint -count=1 ./internal/pgint/ + env: + FELIS_TEST_PG_URL: postgres://felis:pgint@localhost:5432/felis_pgint?sslmode=disable shell: runs-on: ubuntu-latest @@ -66,6 +124,16 @@ jobs: esac done + # A pinned release rather than the runner image's copy, so a runner update cannot + # change what fails. Warnings and errors fail the job; style notes (info) do not. + - name: shellcheck + run: | + curl -fsSL -o shellcheck.tar.xz \ + https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz + echo "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 shellcheck.tar.xz" | sha256sum -c + tar -xJf shellcheck.tar.xz + ./shellcheck-v0.11.0/shellcheck -S warning $(git ls-files '*.sh') + - run: sh deploy/bootstrap_test.sh panel: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46bd536..0d110cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,9 @@ # hash is not listed there, BEFORE it runs it. A release without the file installs by source # build instead. # -# Two jobs, so the write token never meets the test suite: `build` runs the tests, Gradle and -# the Docker build (each of which executes third-party code) with a read-only token and hands -# the binaries over as a workflow artifact; `publish` holds contents:write and runs only +# The write token never meets the test suite: `gates` (ci.yml) and `build` run the tests, +# Gradle and the Docker build (each of which executes third-party code) with a read-only +# token, and `build` hands the binaries over as a workflow artifact; `publish` holds contents:write and runs only # pinned actions and gh. Every action is pinned to a commit SHA (the tag in the trailing # comment is for humans); .github/dependabot.yml proposes the bumps. name: release @@ -37,44 +37,20 @@ permissions: contents: read jobs: + # A tag that ships red is worse than a tag that fails to ship. These are ci.yml's gates, + # called rather than copied: Go (race, vet, staticcheck), govulncheck, the PostgreSQL + # contract suite, shellcheck and the bootstrap tests, the panel, and the Java layer the + # binary EMBEDS (bootstrap_asset.go ships the plugin sources, so a tag whose plugins do + # not compile turns every install of that release into a failed bootstrap). + gates: + uses: ./.github/workflows/ci.yml + build: + needs: gates runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version-file: go.mod - - # A tag that ships red is worse than a tag that fails to ship. - - run: go vet ./... - - run: go test ./... - - # The same reason, for the Java layer the binary EMBEDS: the release asset is - # the tree's plugin sources (bootstrap_asset.go), and a tag whose plugins don't - # compile turns every install of that release into a failed bootstrap. JDK 21 - # gates the install-time plugins + codec/invite tests; JDK 17 gates the loader - # mods (their vendored wrappers fetch their own Gradle). - - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 - with: - distribution: temurin - java-version: '21' - - - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 - with: - gradle-version: '8.14' - - - run: bash plugins/test.sh - - - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 - with: - distribution: temurin - java-version: '17' - - - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 - - - run: bash plugins/test-mods.sh - # Both architectures, because bootstrap's default release channel DOWNLOADS these # rather than compiling on the target host — an arm64 host with no asset silently # falls back to a slow source build. Neither stage is emulated: the Dockerfile pins diff --git a/cmd/felis/fetchcontext.go b/cmd/felis/fetchcontext.go index 6038d5f..d968b49 100644 --- a/cmd/felis/fetchcontext.go +++ b/cmd/felis/fetchcontext.go @@ -227,7 +227,7 @@ func extractTarGz(r io.Reader, root string) error { if err := os.MkdirAll(target, 0o755); err != nil { return fmt.Errorf("create %q: %w", name, err) } - case tar.TypeReg, tar.TypeRegA: + case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return fmt.Errorf("create parent of %q: %w", name, err) } diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 2cac826..b94d9ad 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -54,7 +54,7 @@ # digests are pinned; a rerun moves an installer-managed JRE to the # pinned build). Another feature version is checked against the # digest Adoptium's API publishes for it. -# FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4) +# FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.8) # FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture. # REQUIRED for a non-default FELIS_GO_VERSION; the default's is pinned. # FELIS_K3S_VERSION k3s release a fresh install gets (default: v1.36.4+k3s1). An @@ -214,9 +214,9 @@ FELIS_LEGACY_FORWARDING_SERVERS="${FELIS_LEGACY_FORWARDING_SERVERS:-legacy18}" # The Go tarball is unpacked and run as root, so the default version is pinned by the sha256 # go.dev/dl publishes for each architecture install_go_toolchain handles. Move all three # together; any other FELIS_GO_VERSION has to bring its own FELIS_GO_SHA256. -GO_PINNED_VERSION="1.26.4" -GO_PINNED_SHA256_AMD64="1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" -GO_PINNED_SHA256_ARM64="ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" +GO_PINNED_VERSION="1.26.8" +GO_PINNED_SHA256_AMD64="d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b" +GO_PINNED_SHA256_ARM64="211ffced9dcb9633a55eac6364816ec0ddd951389a740e88fa8b3337971bdda0" FELIS_GO_VERSION="${FELIS_GO_VERSION:-$GO_PINNED_VERSION}" FELIS_GO_SHA256="${FELIS_GO_SHA256:-}" # cloudflared runs as root on the edge, so it gets the same treatment: a pinned release and @@ -1053,8 +1053,8 @@ install_k3s() { # registry-mirror restart below: both restart the agent, and a bootstrap that # proceeds early fails later with a misleading "not found"/timeout instead. wait_for_node_ready() { - local i - for i in $(seq 1 60); do + local _ + for _ in $(seq 1 60); do if kube get nodes 2>/dev/null | grep -q ' Ready '; then ok "k3s node Ready" return 0 @@ -3731,7 +3731,8 @@ write_nano_config() { # own 0700: that directory holds secrets, and install_nano_service reports the lockout # rather than this widening it. if [ ! -d "$STATE_DIR" ]; then - mkdir -p -m 0755 "$STATE_DIR" + mkdir -p "$STATE_DIR" + chmod 0755 "$STATE_DIR" elif [ ! -e "$SECRETS_ENV" ] && [ ! -e "$BOOTSTRAP_DONE" ]; then chmod 0755 "$STATE_DIR" fi diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 5c6bbf2..4b6602f 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -164,7 +164,7 @@ ivblock="$(awk '/^install_velocity\(\) \{/,/^}/' "$BS")" run_velocity_choice() { # FELIS_GAME_STACK FELIS_VELOCITY_VERSION lock-version FELIS_GAME_STACK="$1" FELIS_VELOCITY_VERSION="$2" VELOCITY_VERSION="$3" VELOCITY_LATEST_MINOR=3.5.1 \ VELOCITY_JAR_URL=https://fill-data.papermc.io/v1/objects/aaa/velocity-3.5.1-615.jar VELOCITY_JAR_SHA256=aaa \ - FELIS_VELOCITY_FORK_JAR= bash -c ' + FELIS_VELOCITY_FORK_JAR='' bash -c ' set -Eeuo pipefail log() { :; } die() { printf "DIE: %s\n" "$*"; exit 1; } @@ -993,23 +993,30 @@ rm -f "$mfile" pblock="$(awk '/^push_image_to_registry\(\) \{/,/^}/' "$BS")" [ -n "$pblock" ] || { echo "FAIL: no push_image_to_registry found in $BS"; exit 1; } -run_push() { # ref [docker-push-exit] - REF="$1" PUSH_EXIT="${2:-0}" \ +pushdir="$(mktemp -d)" +run_push() { # ref [failed-pushes-before-success] [registry-read-only] + REF="$1" PUSH_FAILS="${2:-0}" READONLY="${3:-0}" COUNT="$pushdir/count" \ REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 REGISTRY_DOCKER_CONFIG=/cfg \ bash -c ' log() { printf "LOG: %s\n" "$*"; } warn() { printf "WARN: %s\n" "$*"; } die() { printf "DIE: %s\n" "$*"; exit 1; } ok() { :; } + sleep() { :; } systemctl() { :; } + registry_read_only() { [ "$READONLY" = 1 ]; } docker() { printf "DOCKER %s\n" "$*" case " $* " in - *" push "*) return "$PUSH_EXIT" ;; + *" push "*) + n="$(cat "$COUNT" 2>/dev/null || echo 0)" + echo $((n + 1)) > "$COUNT" + [ "$n" -ge "$PUSH_FAILS" ] ;; esac } '"$pblock"' push_image_to_registry "$REF"' + rm -f "$pushdir/count" } out="$(run_push registry.felis.svc:5000/felis/felis:demo)" @@ -1025,6 +1032,16 @@ esac out="$(run_push registry.felis.svc:5000/felis/felis:demo 1)" expect "a failed push fails the install loudly" "DIE: could not mirror" "$out" +out="$(run_push registry.felis.svc:5000/felis/felis:demo 2 1)" +expect "a push refused during a GC window is retried" \ + "WARN: the registry is read-only for garbage collection; retrying the push of 127.0.0.1:5000/felis/felis:demo in 30s (2/40)" "$out" +case "$out" in + *DIE:*) echo "FAIL a push that succeeds after the GC window must not fail the install"; fails=$((fails + 1)) ;; + *) echo "PASS a push that succeeds after the GC window completes" ;; +esac +expect "a GC window that never ends still fails the install" "DIE: could not mirror" \ + "$(run_push registry.felis.svc:5000/felis/felis:demo 99 1)" +rm -rf "$pushdir" # docker must be started ONCE for the whole batch: a start/stop pair per image trips # systemd's start rate limit ("start-limit-hit" — observed live; the 4th image was never @@ -1130,8 +1147,11 @@ expect "the running felis image is pinned" "CTR ctr images label registry.felis. expect "the registry image is pinned by digest" "CTR ctr images label docker.io/library/registry@${regdigest} io.cri-containerd.pinned=pinned" "$out" expect "a previous felis tag is unpinned" "CTR ctr images label registry.felis.svc:5000/felis/felis:v1 io.cri-containerd.pinned=" "$out" expect "the old registry:2 tag is unpinned" "CTR ctr images label docker.io/library/registry:2 io.cri-containerd.pinned=" "$out" +# $'\n' is bash; this file runs under dash in CI. +nl=' +' case "$out" in - *"registry@${regdigest} io.cri-containerd.pinned="$'\n'*) echo "FAIL: the current registry image must not be unpinned"; fails=$((fails + 1)) ;; + *"registry@${regdigest} io.cri-containerd.pinned=${nl}"*) echo "FAIL: the current registry image must not be unpinned"; fails=$((fails + 1)) ;; esac case "$out" in *"limbo:demo io.cri"*) echo "FAIL: only the registry pod's images may be pinned or unpinned"; fails=$((fails + 1)) ;; @@ -1698,7 +1718,7 @@ printf 'JAVA_VERSION="25"\n' > "$vdir/jre/release" run_velocity_service() { # is-active(0|1) ACTIVE="$1" VELOCITY_SERVICE="$vdir/unit" VELOCITY_DIR="$vdir/v" JRE_DIR="$vdir/jre" \ VELOCITY_FINGERPRINT="$vdir/fp" VELOCITY_USER=felis-velocity FELIS_GAME_PORT=25565 \ - FELIS_LEGACY_FORWARDING_SERVERS= bash -c ' + FELIS_LEGACY_FORWARDING_SERVERS='' bash -c ' set -Eeuo pipefail ok() { printf "OK: %s\n" "$*"; } felis_internal_ip() { printf "10.43.0.9"; } @@ -1882,6 +1902,8 @@ expect "a v6 node address gets a v6 rule" "tcp dport 5432 ip6 saddr 2001:db8::7 rm -rf "$fwdir" + + # --------------------------------------------------------------------------------------- if [ "$fails" -eq 0 ]; then echo "ALL PASS" diff --git a/deploy/update-game-stack-lock.sh b/deploy/update-game-stack-lock.sh index d3b25c4..ceedb17 100755 --- a/deploy/update-game-stack-lock.sh +++ b/deploy/update-game-stack-lock.sh @@ -44,8 +44,11 @@ resolve_latest_game_jars log "resolving the newest Velocity ${VELOCITY_LATEST_MINOR} build" velocity="$(papermc_latest_jar velocity "$VELOCITY_LATEST_MINOR")" \ || die "no Velocity build for ${VELOCITY_LATEST_MINOR}" +# shellcheck disable=SC2034 # read back through ${!key} below VELOCITY_VERSION="$VELOCITY_LATEST_MINOR" +# shellcheck disable=SC2034 VELOCITY_JAR_URL="${velocity% *}" +# shellcheck disable=SC2034 VELOCITY_JAR_SHA256="${velocity##* }" tmp="$(mktemp)" diff --git a/internal/config/config.go b/internal/config/config.go index fa8ef44..d9a57d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -505,7 +505,7 @@ func (o OffsiteConfig) validate() error { return fmt.Errorf("config: [offsite] bucket %q must be a bare bucket name; put a key prefix in prefix", o.Bucket) } if strings.Contains(o.Prefix, "..") { - return fmt.Errorf("config: [offsite] prefix %q must not contain ..", o.Prefix) + return fmt.Errorf("config: [offsite] prefix %q must not contain \"..\"", o.Prefix) } if o.DBKeep < 1 { return fmt.Errorf("config: [offsite] db_keep %d must be at least 1", o.DBKeep) diff --git a/internal/registrygate/gate.go b/internal/registrygate/gate.go index 71d526c..560a80c 100644 --- a/internal/registrygate/gate.go +++ b/internal/registrygate/gate.go @@ -81,13 +81,15 @@ type Gate struct { // New builds a Gate for upstream. func New(upstream *url.URL, tokens map[string]string, log *slog.Logger) *Gate { g := &Gate{Tokens: tokens, Upstream: upstream, Log: log} - rp := httputil.NewSingleHostReverseProxy(upstream) - base := rp.Director - rp.Director = func(r *http.Request) { - base(r) + rp := &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(upstream) + // The registry builds upload Location URLs from Host: keep the one the client + // dialled, not the loopback upstream. + pr.Out.Host = pr.In.Host + pr.SetXForwarded() // The registry has no auth of its own; the credential stops here. - r.Header.Del("Authorization") - } + pr.Out.Header.Del("Authorization") + }} // Blob uploads and pulls are streamed; flush as bytes arrive so a large layer // pull is not buffered in the gate. rp.FlushInterval = -1