diff --git a/.github/workflows/refresh-pins.yml b/.github/workflows/refresh-pins.yml new file mode 100644 index 0000000..1e42206 --- /dev/null +++ b/.github/workflows/refresh-pins.yml @@ -0,0 +1,109 @@ +name: refresh image pins + +# Every module's default image is pinned to a digest, so upstream releases no +# longer reach users on their own. This workflow is how they reach users +# deliberately: it re-resolves each pinned tag to whatever digest it points at +# today and opens a PR with the diff. +# +# It NEVER pushes to main and NEVER auto-merges. A pin refresh means "the game +# binary changes for everyone on next restart", which is exactly the event +# pinning exists to put a human in front of. Review the PR, and let the +# `validate modules` workflow re-check the new digests against their OCI +# configs before merging. +# +# Entries tagged `# gameplane:floating` in a template are skipped by the +# pinner and stay moving on purpose (factorio's Experimental channel, +# terraria's tModLoader preview). + +on: + workflow_dispatch: + schedule: + # 04:00 UTC on the 1st of each month. Monthly rather than nightly: these + # are game-server images, and a PR nobody has time to review is worse than + # no PR at all. + - cron: "0 4 1 * *" + +permissions: + contents: write + pull-requests: write + +jobs: + refresh: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PyYAML + run: pip install --disable-pip-version-check --user pyyaml + + - name: Re-resolve every pinned tag + id: pin + # `--pin` exits non-zero when a ref could not be resolved. It leaves + # those refs untouched rather than writing a guessed digest, so a + # partial refresh is still a safe diff — surface it in the PR body + # instead of failing the job outright. + run: | + set +e + python3 validate.py --pin | tee /tmp/pin.log + echo "exit=$?" >> "$GITHUB_OUTPUT" + + - name: Check for changes + id: diff + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No digest movement — every pinned tag still resolves to the same image." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git diff --stat + fi + + - name: Re-validate the refreshed pins + if: steps.diff.outputs.changed == 'true' + # Non-blocking: the point is to attach the validator's opinion of the + # NEW images to the PR, not to stop the PR from being opened. A new + # upstream build that trips a rule is precisely what a reviewer needs + # to see. + continue-on-error: true + run: python3 validate.py | tee /tmp/validate.log + + - name: Open a pull request + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # NOTE: a PR opened with the default GITHUB_TOKEN does not itself + # trigger other workflows, so `validate modules` will not run on it + # automatically. The validator output is attached to the PR body + # above; close and reopen the PR (or push an empty commit) to get a + # real CI run before merging. + run: | + BRANCH="chore/refresh-image-pins-$(date -u +%Y%m%d)" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + # Two -m flags rather than one embedded newline: an unindented + # continuation line would terminate this YAML block scalar. + git commit -as \ + -m "chore: refresh module image pins" \ + -m "Re-resolved every pinned tag to its current digest. Review what actually moved before merging - each changed digest is a game binary that changes for every server on its next restart." + git push origin "$BRANCH" + { + echo "Automated pin refresh. **Each changed digest changes the game binary**" + echo "for every server using that module on its next pod restart." + echo + echo "## Pinner output" + echo '```' + cat /tmp/pin.log + echo '```' + echo "## Validator output for the new images" + echo '```' + cat /tmp/validate.log 2>/dev/null || echo "(validator did not run)" + echo '```' + } > /tmp/body.md + gh pr create --title "chore: refresh module image pins" \ + --body-file /tmp/body.md --base main --head "$BRANCH" diff --git a/7-days-to-die/template.yaml b/7-days-to-die/template.yaml index 58c9d19..41a5f20 100644 --- a/7-days-to-die/template.yaml +++ b/7-days-to-die/template.yaml @@ -56,7 +56,28 @@ spec: Gameplane's single data volume can't all cover at once, and world persistence was the fix this template needed. - image: vinanrra/7dtd-server:latest + image: vinanrra/7dtd-server:latest@sha256:e3c479866744a65c1bec2b1a4691e08baff2938cf674d4674cd9e8975cd21e4c + + versions: + - id: v0-9-3 + displayName: "Image v0.9.3" + image: vinanrra/7dtd-server:v0.9.3@sha256:75d1193fb2470687ee8748f27482524c170f6cbca110e15f48db8d8ff48f15ca + default: true + - id: v0-9-2 + displayName: "Image v0.9.2" + image: vinanrra/7dtd-server:v0.9.2@sha256:2ba7b9d4b1a7f66b5b7e00755f62169583930e64c49cf400b69c4f1f2d126a9c + - id: v0-9-1 + displayName: "Image v0.9.1" + image: vinanrra/7dtd-server:v0.9.1@sha256:f6c6572a77c1503d6ddafe08fdc873daff974e24ef3a929881b4d184aae8c2ca + - id: v0-9-0 + displayName: "Image v0.9.0" + image: vinanrra/7dtd-server:v0.9.0@sha256:83d203f74af22f8bef9600723c7463845070f0724142b09d6d89f7ffe1c86a19 + - id: v0-7-10 + displayName: "Image v0.7.10" + image: vinanrra/7dtd-server:v0.7.10@sha256:8e5cadc01af04a53db2832ae46f919275cec484b5d58350af7e8aad6fff83af5 + - id: latest + displayName: "Latest (moves on restart)" + image: vinanrra/7dtd-server:latest # gameplane:floating env: # Required by the image — with no env at all (the previous draft's diff --git a/ark-survival-ascended/template.yaml b/ark-survival-ascended/template.yaml index f1b010a..b6bc488 100644 --- a/ark-survival-ascended/template.yaml +++ b/ark-survival-ascended/template.yaml @@ -68,7 +68,25 @@ spec: RCON-backed console and moderation. CurseForge mod management via the Mods tab. - image: mschnitzer/asa-linux-server:latest + image: mschnitzer/asa-linux-server:latest@sha256:6a62f7c6eb77ba4b4b79ef6c2981a0bd9fab287e744d9797e66efc762ebb811e + + versions: + - id: 1-5-1 + displayName: "Image 1.5.1" + image: mschnitzer/asa-linux-server:1.5.1@sha256:6a62f7c6eb77ba4b4b79ef6c2981a0bd9fab287e744d9797e66efc762ebb811e + default: true + - id: 1-5-0 + displayName: "Image 1.5.0" + image: mschnitzer/asa-linux-server:1.5.0@sha256:4b8df632b2070af60fddd6f77cc43f6397feed06ca7f29d80455e9b6ac2057d5 + - id: 1-4-1 + displayName: "Image 1.4.1" + image: mschnitzer/asa-linux-server:1.4.1@sha256:b2b7bad7b388853b7f152723f6dcbf0a40504beefb23568def85f61b56028cb9 + - id: 1-4-0 + displayName: "Image 1.4.0" + image: mschnitzer/asa-linux-server:1.4.0@sha256:c16a19aef7804e38ec4d6860f1ca038ccd83d606dd221877bccc862446eb8a85 + - id: latest + displayName: "Latest (moves on restart)" + image: mschnitzer/asa-linux-server:latest # gameplane:floating # REQUIRED. The image declares no ENTRYPOINT and its CMD is /bin/bash, so # without this the container gets a non-interactive bash, which reads EOF diff --git a/cs2/template.yaml b/cs2/template.yaml index 91f31dc..0ffc2c7 100644 --- a/cs2/template.yaml +++ b/cs2/template.yaml @@ -32,7 +32,25 @@ spec: the console and the broadcast/stop actions; Metamod and CounterStrikeSharp plugins install under `game/csgo/addons`. - image: joedwards32/cs2:latest + image: joedwards32/cs2:latest@sha256:41b826d6280d1aa9e41c866a6d88cc7f523e027f16c47a313b20c2d7a17f2680 + + versions: + - id: 4-0-1 + displayName: "Image 4.0.1" + image: joedwards32/cs2:4.0.1@sha256:34c093d24a23751fb409ae41dffe88884fb36964b365fcd25a31ec45e88ad8cf + default: true + - id: 4-0-0 + displayName: "Image 4.0.0" + image: joedwards32/cs2:4.0.0@sha256:36febab2abdabbeffa06edfda88c1d5603805a17287ae0658e23d07d5d07cda7 + - id: 3-0-9 + displayName: "Image 3.0.9" + image: joedwards32/cs2:3.0.9@sha256:bd6eec1fb4ae614c9c88680aaab41c7a43de40825476528f7542694ea213e13d + - id: 3-0-7 + displayName: "Image 3.0.7" + image: joedwards32/cs2:3.0.7@sha256:5ef9a47b92cb017d6d94f2723bdad8a76be0dfcb24c2d438b257d14ed51697b5 + - id: latest + displayName: "Latest (moves on restart)" + image: joedwards32/cs2:latest # gameplane:floating # REQUIRED. The image config declares User "1000:1000" — it starts as a # non-root user with no root step to self-chown, so a fresh PVC is EACCES diff --git a/dayz/template.yaml b/dayz/template.yaml index 0bf48a1..9a6d2dd 100644 --- a/dayz/template.yaml +++ b/dayz/template.yaml @@ -45,7 +45,16 @@ spec: BattlEye targets players by number rather than name, so a button would risk hitting the wrong one — moderate in-game instead. - image: registry.godbleak.dev/godbleak/serverz:latest + image: registry.godbleak.dev/godbleak/serverz:latest@sha256:5e8757beae763c862d08a9c08587c35211cda74ce399f7419492d7520adab4fe + + versions: + - id: 2-0-0 + displayName: "Image 2.0.0" + image: registry.godbleak.dev/godbleak/serverz:2.0.0@sha256:5e8757beae763c862d08a9c08587c35211cda74ce399f7419492d7520adab4fe + default: true + - id: latest + displayName: "Latest (moves on restart)" + image: registry.godbleak.dev/godbleak/serverz:latest # gameplane:floating env: # Fixed to match the declared ports[] below. diff --git a/dont-starve-together/template.yaml b/dont-starve-together/template.yaml index 9b03da6..3f8f849 100644 --- a/dont-starve-together/template.yaml +++ b/dont-starve-together/template.yaml @@ -12,7 +12,20 @@ spec: categories: [Survival, Co-op] accentColor: "#cc0000" description: DST dedicated server. - image: jamesits/dst-server:latest + image: jamesits/dst-server:latest@sha256:fa61065f8d2d770bc5d45f1a160b87b1deada3fd5903d9524b771321ca98dc58 + + versions: + - id: vanilla + displayName: "Vanilla" + image: jamesits/dst-server:vanilla@sha256:fa61065f8d2d770bc5d45f1a160b87b1deada3fd5903d9524b771321ca98dc58 + default: true + - id: nightly + displayName: "Nightly (moving)" + image: jamesits/dst-server:nightly # gameplane:floating + - id: latest + displayName: "Latest" + image: jamesits/dst-server:latest # gameplane:floating + ports: - name: game containerPort: 10999 diff --git a/enshrouded/template.yaml b/enshrouded/template.yaml index 76b1a07..731df89 100644 --- a/enshrouded/template.yaml +++ b/enshrouded/template.yaml @@ -12,7 +12,23 @@ spec: categories: [Survival, Co-op, Building, Adventure] accentColor: "#2a4b7c" description: Enshrouded dedicated server. - image: mornedhels/enshrouded-server:latest + image: mornedhels/enshrouded-server:latest@sha256:85978a10f88a85ab0a0aa92e9821d30424895d38bf81fe543532451219c42d0d + + versions: + - id: 1-7-2 + displayName: "Game v1.7.2" + image: mornedhels/enshrouded-server:1.7.2@sha256:85978a10f88a85ab0a0aa92e9821d30424895d38bf81fe543532451219c42d0d + default: true + - id: 1-7-1 + displayName: "Game v1.7.1" + image: mornedhels/enshrouded-server:1.7.1@sha256:67fe03f7a7b9932d269757604c97b85bbee125270c5cfc746d00dd21a2309ddc + - id: 1-7-0 + displayName: "Game v1.7.0" + image: mornedhels/enshrouded-server:1.7.0@sha256:ca12415986fe84e585a2bd6cf5ba0a822a5c85c6caf1d557f5e607fdb2a84682 + - id: latest + displayName: "Latest (moves on restart)" + image: mornedhels/enshrouded-server:latest # gameplane:floating + ports: - name: game containerPort: 15636 diff --git a/factorio/template.yaml b/factorio/template.yaml index efa6f34..831251b 100644 --- a/factorio/template.yaml +++ b/factorio/template.yaml @@ -43,7 +43,7 @@ spec: # Fallback image when a server selects no version. versions[] below is the # authoritative source for the wizard's picker. - image: factoriotools/factorio:stable + image: factoriotools/factorio:stable@sha256:7052b3cca8ca7790f99f4058617d5c8089df544de736b1baa23f2c5f58fb7f48 env: # Load the newest save on boot (the entrypoint generates one on first @@ -91,16 +91,16 @@ spec: versions: - id: stable displayName: "Stable channel" - image: factoriotools/factorio:stable + image: factoriotools/factorio:stable@sha256:7052b3cca8ca7790f99f4058617d5c8089df544de736b1baa23f2c5f58fb7f48 loader: native default: true - id: latest displayName: "Experimental channel" - image: factoriotools/factorio:latest + image: factoriotools/factorio:latest # gameplane:floating loader: native - id: stable-2-0-77 displayName: "2.0.77 (pinned stable)" - image: factoriotools/factorio:stable-2.0.77 + image: factoriotools/factorio:stable-2.0.77@sha256:7052b3cca8ca7790f99f4058617d5c8089df544de736b1baa23f2c5f58fb7f48 loader: native gameVersion: "2.0" diff --git a/garrys-mod/template.yaml b/garrys-mod/template.yaml index d254ed8..71c005e 100644 --- a/garrys-mod/template.yaml +++ b/garrys-mod/template.yaml @@ -91,7 +91,7 @@ spec: `addons/`, `gamemodes/`, and `cfg/` into the image and only persists `garrysmod/data`). - image: ceifa/garrysmod:latest + image: ceifa/garrysmod:latest@sha256:26439fe29c2d66f4241a4db46e3c7088e9eb37356f2bd441dc0c3c86e8acb180 env: # Debian production image variant (per the image's own recommendation diff --git a/minecraft-java/template.yaml b/minecraft-java/template.yaml index 3537f0b..fc4e4cd 100644 --- a/minecraft-java/template.yaml +++ b/minecraft-java/template.yaml @@ -37,7 +37,7 @@ spec: # Fallback image when a server selects no version (versions[] is the # authoritative source for the wizard). The tag only pins the itzg # release / Java runtime — TYPE and VERSION env select the software. - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a # INIT_MEMORY/MAX_MEMORY are configSchema fields (schema-resolved config # overrides template env on name collisions), so the JVM heap is visible @@ -97,21 +97,21 @@ spec: # ── Latest (moving) ──────────────────────────────────────────────── - id: "latest-paper" displayName: "Latest · Paper" - image: itzg/minecraft-server:java25 + image: itzg/minecraft-server:java25@sha256:9faa6aefeedd5a883c3ee241653fd1421529bdbafc428d0513e43cae0f2b7d68 loader: paper env: - { name: TYPE, value: PAPER } - { name: VERSION, value: LATEST } - id: "latest-fabric" displayName: "Latest · Fabric" - image: itzg/minecraft-server:java25 + image: itzg/minecraft-server:java25@sha256:9faa6aefeedd5a883c3ee241653fd1421529bdbafc428d0513e43cae0f2b7d68 loader: fabric env: - { name: TYPE, value: FABRIC } - { name: VERSION, value: LATEST } - id: "latest-vanilla" displayName: "Latest · Vanilla" - image: itzg/minecraft-server:java25 + image: itzg/minecraft-server:java25@sha256:9faa6aefeedd5a883c3ee241653fd1421529bdbafc428d0513e43cae0f2b7d68 loader: vanilla env: - { name: TYPE, value: VANILLA } @@ -119,7 +119,7 @@ spec: # ── 1.21.4 ───────────────────────────────────────────────────────── - id: "1.21.4-paper" displayName: "1.21.4 · Paper" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: paper gameVersion: "1.21.4" default: true @@ -128,7 +128,7 @@ spec: - { name: VERSION, value: "1.21.4" } - id: "1.21.4-purpur" displayName: "1.21.4 · Purpur" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: purpur gameVersion: "1.21.4" env: @@ -136,7 +136,7 @@ spec: - { name: VERSION, value: "1.21.4" } - id: "1.21.4-fabric" displayName: "1.21.4 · Fabric" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: fabric gameVersion: "1.21.4" env: @@ -144,7 +144,7 @@ spec: - { name: VERSION, value: "1.21.4" } - id: "1.21.4-quilt" displayName: "1.21.4 · Quilt" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: quilt gameVersion: "1.21.4" env: @@ -152,7 +152,7 @@ spec: - { name: VERSION, value: "1.21.4" } - id: "1.21.4-forge" displayName: "1.21.4 · Forge" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: forge gameVersion: "1.21.4" env: @@ -160,7 +160,7 @@ spec: - { name: VERSION, value: "1.21.4" } - id: "1.21.4-neoforge" displayName: "1.21.4 · NeoForge" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: neoforge gameVersion: "1.21.4" env: @@ -168,7 +168,7 @@ spec: - { name: VERSION, value: "1.21.4" } - id: "1.21.4-vanilla" displayName: "1.21.4 · Vanilla" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: vanilla gameVersion: "1.21.4" env: @@ -177,7 +177,7 @@ spec: # ── 1.21.1 (NeoForge LTS line) ───────────────────────────────────── - id: "1.21.1-paper" displayName: "1.21.1 · Paper" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: paper gameVersion: "1.21.1" env: @@ -185,7 +185,7 @@ spec: - { name: VERSION, value: "1.21.1" } - id: "1.21.1-fabric" displayName: "1.21.1 · Fabric" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: fabric gameVersion: "1.21.1" env: @@ -193,7 +193,7 @@ spec: - { name: VERSION, value: "1.21.1" } - id: "1.21.1-neoforge" displayName: "1.21.1 · NeoForge" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: neoforge gameVersion: "1.21.1" env: @@ -201,7 +201,7 @@ spec: - { name: VERSION, value: "1.21.1" } - id: "1.21.1-vanilla" displayName: "1.21.1 · Vanilla" - image: itzg/minecraft-server:java21 + image: itzg/minecraft-server:java21@sha256:f71555873bdaa6f4b663563de58678cca334bc51bdfbe548f7958ee9f516cf4a loader: vanilla gameVersion: "1.21.1" env: @@ -210,7 +210,7 @@ spec: # ── 1.20.x ───────────────────────────────────────────────────────── - id: "1.20.4-paper" displayName: "1.20.4 · Paper" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: paper gameVersion: "1.20.4" env: @@ -218,7 +218,7 @@ spec: - { name: VERSION, value: "1.20.4" } - id: "1.20.4-fabric" displayName: "1.20.4 · Fabric" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: fabric gameVersion: "1.20.4" env: @@ -226,7 +226,7 @@ spec: - { name: VERSION, value: "1.20.4" } - id: "1.20.4-forge" displayName: "1.20.4 · Forge" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: forge gameVersion: "1.20.4" env: @@ -234,7 +234,7 @@ spec: - { name: VERSION, value: "1.20.4" } - id: "1.20.1-paper" displayName: "1.20.1 · Paper" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: paper gameVersion: "1.20.1" env: @@ -242,7 +242,7 @@ spec: - { name: VERSION, value: "1.20.1" } - id: "1.20.1-fabric" displayName: "1.20.1 · Fabric" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: fabric gameVersion: "1.20.1" env: @@ -250,7 +250,7 @@ spec: - { name: VERSION, value: "1.20.1" } - id: "1.20.1-forge" displayName: "1.20.1 · Forge" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: forge gameVersion: "1.20.1" env: @@ -259,7 +259,7 @@ spec: # ── 1.19.4 / 1.18.2 (classic modded era) ─────────────────────────── - id: "1.19.4-paper" displayName: "1.19.4 · Paper" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: paper gameVersion: "1.19.4" env: @@ -267,7 +267,7 @@ spec: - { name: VERSION, value: "1.19.4" } - id: "1.19.4-forge" displayName: "1.19.4 · Forge" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: forge gameVersion: "1.19.4" env: @@ -275,7 +275,7 @@ spec: - { name: VERSION, value: "1.19.4" } - id: "1.18.2-paper" displayName: "1.18.2 · Paper" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: paper gameVersion: "1.18.2" env: @@ -283,7 +283,7 @@ spec: - { name: VERSION, value: "1.18.2" } - id: "1.18.2-forge" displayName: "1.18.2 · Forge" - image: itzg/minecraft-server:java17 + image: itzg/minecraft-server:java17@sha256:bc3a9368c380398965b9221838f96c69ad07829ab0610adeacd9af123e6c0dfc loader: forge gameVersion: "1.18.2" env: @@ -292,7 +292,7 @@ spec: # ── 1.16.5 (Java 8 era) ──────────────────────────────────────────── - id: "1.16.5-paper" displayName: "1.16.5 · Paper" - image: itzg/minecraft-server:java8-multiarch + image: itzg/minecraft-server:java8-multiarch@sha256:d29dddde92fe6811f90d430b10ff6b382121533000992673fb5f8a316cc2e5e6 loader: paper gameVersion: "1.16.5" env: @@ -300,7 +300,7 @@ spec: - { name: VERSION, value: "1.16.5" } - id: "1.16.5-forge" displayName: "1.16.5 · Forge" - image: itzg/minecraft-server:java8-multiarch + image: itzg/minecraft-server:java8-multiarch@sha256:d29dddde92fe6811f90d430b10ff6b382121533000992673fb5f8a316cc2e5e6 loader: forge gameVersion: "1.16.5" env: diff --git a/palworld/template.yaml b/palworld/template.yaml index 7cded6d..8e7407a 100644 --- a/palworld/template.yaml +++ b/palworld/template.yaml @@ -50,7 +50,7 @@ spec: # Fallback image when a server selects no version. versions[] below is the # authoritative source for the wizard's picker. - image: thijsvanloef/palworld-server-docker:latest + image: thijsvanloef/palworld-server-docker:latest@sha256:4145c58737fcd9f9f03d35de6bf33c0008b829b65d3080822bc46830bdb38fdf # REQUIRED. The image starts as root but its entrypoint DROPS privileges to # PUID/PGID (1000). The data PVC is root-owned, so the dropped-privilege @@ -120,16 +120,16 @@ spec: versions: - id: latest displayName: "Latest wrapper" - image: thijsvanloef/palworld-server-docker:latest + image: thijsvanloef/palworld-server-docker:latest@sha256:4145c58737fcd9f9f03d35de6bf33c0008b829b65d3080822bc46830bdb38fdf loader: pak default: true - id: v2 displayName: "v2 wrapper (major line)" - image: thijsvanloef/palworld-server-docker:v2 + image: thijsvanloef/palworld-server-docker:v2@sha256:4145c58737fcd9f9f03d35de6bf33c0008b829b65d3080822bc46830bdb38fdf loader: pak - id: v2-4-1 displayName: "v2.4.1 wrapper (pinned)" - image: thijsvanloef/palworld-server-docker:v2.4.1 + image: thijsvanloef/palworld-server-docker:v2.4.1@sha256:ca18aa7ee82d4a0aaa52cbba4437633bf7cf3dda67bf570188c71d232374f6c3 loader: pak capabilities: diff --git a/project-zomboid/template.yaml b/project-zomboid/template.yaml index d56d1c0..0e5a1dc 100644 --- a/project-zomboid/template.yaml +++ b/project-zomboid/template.yaml @@ -44,7 +44,19 @@ spec: RCON-backed console and moderation, Steam Workshop mods via the two ID lists Project Zomboid itself uses to sync content. - image: sknnr/zomboid-dedicated-server:latest + image: sknnr/zomboid-dedicated-server:latest@sha256:bcb7e2486214b93ee125051e888c87cfbe3aa2654897d944a6254272b7b0ab74 + + versions: + - id: v1-1-1 + displayName: "Image v1.1.1" + image: sknnr/zomboid-dedicated-server:v1.1.1@sha256:bcb7e2486214b93ee125051e888c87cfbe3aa2654897d944a6254272b7b0ab74 + default: true + - id: v1-0-0 + displayName: "Image v1.0.0" + image: sknnr/zomboid-dedicated-server:v1.0.0@sha256:e8f202e8203ff6a67f5fe78c9d2fdedd85f644421cbe18f2805845ba4b821b0a + - id: latest + displayName: "Latest (moves on restart)" + image: sknnr/zomboid-dedicated-server:latest # gameplane:floating # REQUIRED. The image is rootless. Its README SAYS uid 1000 — that is WRONG; # the image's own /etc/passwd has `steam:x:10000:10000` and /home/steam is diff --git a/rust/template.yaml b/rust/template.yaml index 9dfafc7..cb92514 100644 --- a/rust/template.yaml +++ b/rust/template.yaml @@ -36,7 +36,7 @@ spec: (Facepunch's webrcon). Oxide plugins are supported via the version picker. - image: didstopia/rust-server:latest + image: didstopia/rust-server:latest@sha256:a16589d9182245faee4353cd99e59965e6a9bec17d3d757bb427c48a17546a2b ports: - name: game @@ -155,14 +155,14 @@ spec: versions: - id: vanilla displayName: Vanilla - image: didstopia/rust-server:latest + image: didstopia/rust-server:latest@sha256:a16589d9182245faee4353cd99e59965e6a9bec17d3d757bb427c48a17546a2b loader: vanilla default: true env: - { name: RUST_OXIDE_ENABLED, value: "0" } - id: oxide displayName: Oxide - image: didstopia/rust-server:latest + image: didstopia/rust-server:latest@sha256:a16589d9182245faee4353cd99e59965e6a9bec17d3d757bb427c48a17546a2b loader: oxide env: - { name: RUST_OXIDE_ENABLED, value: "1" } diff --git a/satisfactory/template.yaml b/satisfactory/template.yaml index 1ea766a..f2a194b 100644 --- a/satisfactory/template.yaml +++ b/satisfactory/template.yaml @@ -65,7 +65,7 @@ spec: # Fallback image when a server selects no version. versions[] below is the # authoritative source for the wizard's picker. - image: wolveix/satisfactory-server:latest + image: wolveix/satisfactory-server:latest@sha256:e103700ae6ae4c50f19dac80eadb2a805c5b885e179ae2a40850e967bf189efd env: # Install/update the game via steamcmd at container start. Required on the @@ -131,14 +131,14 @@ spec: versions: - id: stable displayName: "Stable" - image: wolveix/satisfactory-server:latest + image: wolveix/satisfactory-server:latest@sha256:e103700ae6ae4c50f19dac80eadb2a805c5b885e179ae2a40850e967bf189efd default: true env: - name: STEAMBETA value: "false" - id: experimental displayName: "Experimental" - image: wolveix/satisfactory-server:latest + image: wolveix/satisfactory-server:latest@sha256:e103700ae6ae4c50f19dac80eadb2a805c5b885e179ae2a40850e967bf189efd env: - name: STEAMBETA value: "true" diff --git a/terraria/template.yaml b/terraria/template.yaml index 2ad97a8..b0cb2c3 100644 --- a/terraria/template.yaml +++ b/terraria/template.yaml @@ -34,7 +34,7 @@ spec: # Fallback image when a server selects no version. versions[] below is the # authoritative source for the wizard's picker. - image: passivelemon/terraria-docker:terraria-latest + image: passivelemon/terraria-docker:terraria-latest@sha256:d60f280522d6c71079638b5036bdd6c4ac9f93c9f3250477ddb345fc2c6933f3 ports: - name: game @@ -67,23 +67,23 @@ spec: versions: - id: vanilla-latest displayName: "Vanilla · latest" - image: passivelemon/terraria-docker:terraria-latest + image: passivelemon/terraria-docker:terraria-latest@sha256:d60f280522d6c71079638b5036bdd6c4ac9f93c9f3250477ddb345fc2c6933f3 loader: vanilla default: true - id: tmodloader-latest displayName: "tModLoader · latest" - image: passivelemon/terraria-docker:tmodloader-latest + image: passivelemon/terraria-docker:tmodloader-latest@sha256:3f2d8703421159f1037084bd2c0901a3a63b85a00801cf36f6f928e8b666b44e loader: tmodloader # Year channel: follows tModLoader's 2026.x stable line without jumping # to the next yearly major — steadier for servers with version-sensitive # mod sets than the plain latest tag. - id: tmodloader-2026 displayName: "tModLoader · 2026 stable" - image: passivelemon/terraria-docker:tmodloader-2026 + image: passivelemon/terraria-docker:tmodloader-2026@sha256:3f2d8703421159f1037084bd2c0901a3a63b85a00801cf36f6f928e8b666b44e loader: tmodloader - id: tmodloader-preview displayName: "tModLoader · latest preview" - image: passivelemon/terraria-docker:tmodloader-latest-pre + image: passivelemon/terraria-docker:tmodloader-latest-pre # gameplane:floating loader: tmodloader probes: diff --git a/v-rising/template.yaml b/v-rising/template.yaml index eec7273..13ec88f 100644 --- a/v-rising/template.yaml +++ b/v-rising/template.yaml @@ -36,7 +36,22 @@ spec: [trueosiris/vrising](https://github.com/TrueOsiris/docker-vrising). Source RCON powers the console, broadcast, and stop actions. - image: trueosiris/vrising:latest + image: trueosiris/vrising:latest@sha256:9356f98ad56139cccf4cfc7a62331d02e4216e8156d990792f5050252405bc7d + + versions: + - id: 2026-04-23-1036 + displayName: "Image 2026-04-23-1036" + image: trueosiris/vrising:2026-04-23-1036@sha256:9356f98ad56139cccf4cfc7a62331d02e4216e8156d990792f5050252405bc7d + default: true + - id: 3-0 + displayName: "Image 3.0" + image: trueosiris/vrising:3.0@sha256:a58cb84e3d69acf2d965ec752ee3a13ce5d10eea1808b536762fbd7048af71c2 + - id: 2-1 + displayName: "Image 2.1" + image: trueosiris/vrising:2.1@sha256:22e4c7f6f7ec4bb3395bb840d40e823c5df381fc90f88914f5e56798539471e7 + - id: latest + displayName: "Latest (moves on restart)" + image: trueosiris/vrising:latest # gameplane:floating # Turns RCON on in ServerHostSettings.json via the image's generic # HOST_SETTINGS_ passthrough (double-underscore = nested JSON key). The diff --git a/valheim/template.yaml b/valheim/template.yaml index 1b42818..0567642 100644 --- a/valheim/template.yaml +++ b/valheim/template.yaml @@ -32,7 +32,7 @@ spec: World persistence, password, crossplay, and BepInEx mods. Choose the stable or public-test channel in the New Server wizard. - image: lloesche/valheim-server:latest + image: lloesche/valheim-server:latest@sha256:20fde516ce311e6084f82f295c9eb6934af57b357c657937a04f62bdf5946149 env: - name: STATUS_HTTP @@ -87,14 +87,14 @@ spec: versions: - id: stable displayName: "Stable" - image: lloesche/valheim-server:latest + image: lloesche/valheim-server:latest@sha256:20fde516ce311e6084f82f295c9eb6934af57b357c657937a04f62bdf5946149 loader: bepinex default: true env: - { name: PUBLIC_TEST, value: "false" } - id: public-test displayName: "Public Test (beta)" - image: lloesche/valheim-server:latest + image: lloesche/valheim-server:latest@sha256:20fde516ce311e6084f82f295c9eb6934af57b357c657937a04f62bdf5946149 loader: bepinex env: - { name: PUBLIC_TEST, value: "true" } diff --git a/validate.py b/validate.py index cf5dbff..28c2e22 100644 --- a/validate.py +++ b/validate.py @@ -92,6 +92,8 @@ from __future__ import annotations import json +import os +import re import subprocess import sys from dataclasses import dataclass @@ -188,16 +190,196 @@ def _curl_json(url: str, headers: dict[str, str] | None = None): return status, None, body +def _curl_headers(url: str, headers: dict[str, str] | None = None) -> tuple[int | None, dict[str, str]]: + """Run curl and return (http_status_or_None, response_headers). + + The body is discarded; only headers are read. `-L` means redirects can + produce several header blocks, so keys are overwritten as they arrive and + the LAST value for a name wins — that is the one belonging to the + response actually served. Header names are lower-cased (HTTP header + names are case-insensitive and registries disagree on casing). + """ + cmd = [ + "curl", "-s", "-L", "-4", "-m", str(CURL_TIMEOUT_SECS), + "-o", os.devnull, "-D", "-", "-w", "\n%{http_code}", + ] + for k, v in (headers or {}).items(): + cmd += ["-H", f"{k}: {v}"] + cmd += [url] + try: + out = subprocess.run( + cmd, capture_output=True, timeout=CURL_TIMEOUT_SECS + 10, check=False + ) + except (subprocess.TimeoutExpired, OSError): + return None, {} + if out.returncode != 0: + return None, {} + text = out.stdout.decode("utf-8", "replace") + idx = text.rfind("\n") + if idx == -1: + return None, {} + try: + status = int(text[idx + 1 :]) + except ValueError: + return None, {} + parsed: dict[str, str] = {} + for line in text[:idx].splitlines(): + if ":" not in line: + continue + name, _, value = line.partition(":") + parsed[name.strip().lower()] = value.strip() + return status, parsed + + +def _parse_auth_challenge(header: str) -> dict[str, str]: + """Pull the key="value" pairs out of a `WWW-Authenticate: Bearer …` header.""" + return dict(re.findall(r'(\w+)="([^"]*)"', header)) + + +def _registry_token(registry_host: str | None, repo: str) -> tuple[str | None, str, dict | None]: + """Get a pull token for repo, returning (token, api_base, error_or_None). + + Docker Hub keeps its known-good hardcoded flow. Every other registry is + resolved through the standard OCI bearer challenge: request /v2/, read + the realm/service out of the 401's WWW-Authenticate header, and exchange + them for an anonymous pull token. That is not Docker-Hub-specific — it is + how the distribution spec says a client discovers auth — and it is what + lets the one module on a self-hosted registry (dayz, on a GitLab + container registry) be checked and pinned like every other. + + A registry that requires real credentials returns no token; the caller + reports that as a skip, not a failure, since the validator has no + business holding registry credentials. + """ + if registry_host is None: + api_base = "https://registry-1.docker.io" + token_url = ( + f"https://auth.docker.io/token?service=registry.docker.io" + f"&scope=repository:{repo}:pull" + ) + else: + api_base = f"https://{registry_host}" + status, headers = _curl_headers(f"{api_base}/v2/") + if status is None: + return None, api_base, { + "ok": False, "skipped": False, "network_error": True, + "reason": f"could not reach registry {registry_host!r}", + } + # An open registry answers /v2/ with 200 and needs no token at all. + if status == 200: + return "", api_base, None + challenge = headers.get("www-authenticate", "") + if not challenge.lower().startswith("bearer"): + return None, api_base, { + "ok": False, "skipped": True, "network_error": False, + "reason": ( + f"registry {registry_host!r} answered /v2/ with HTTP {status} and no " + "bearer challenge — it needs credentials this validator does not hold; " + "skipping (not a failure)" + ), + } + parts = _parse_auth_challenge(challenge) + realm = parts.get("realm") + if not realm: + return None, api_base, { + "ok": False, "skipped": True, "network_error": False, + "reason": f"registry {registry_host!r} sent a bearer challenge with no realm; skipping", + } + token_url = f"{realm}?service={parts.get('service', '')}&scope=repository:{repo}:pull" + + status, body, raw = _curl_json(token_url) + if status is None: + return None, api_base, { + "ok": False, "skipped": False, "network_error": True, + "reason": f"could not reach the token endpoint ({raw[:200]!r})", + } + if status != 200 or not body: + return None, api_base, { + "ok": False, "skipped": False, "network_error": True, + "reason": f"token endpoint returned HTTP {status}", + } + # Docker Hub returns `token`; the OCI spec also allows `access_token`, + # which is what GitLab's registry returns. + token = body.get("token") or body.get("access_token") + if not token: + return None, api_base, { + "ok": False, "skipped": True, "network_error": False, + "reason": ( + f"registry issued no anonymous pull token for {repo!r} — it likely " + "requires credentials; skipping (not a failure)" + ), + } + return token, api_base, None + + +def resolve_digest(image_ref: str) -> dict[str, Any]: + """Resolve an image reference to the digest its tag currently points at. + + Return shape mirrors fetch_image_config: {"ok": True, "digest": "sha256:…"} + or {"ok": False, "skipped": bool, "network_error": bool, "reason": str}. + + This deliberately returns the digest of the TAG manifest — which for a + multi-arch image is the index (manifest-list) digest, covering every + platform the tag publishes. It must never be confused with the + per-platform digest fetch_image_config drills down to: pinning that + would silently reduce a module to a single architecture and break the + arm64 half of the e2e matrix. + """ + registry_host, repo, reference = _parse_ref(image_ref) + token, api_base, err = _registry_token(registry_host, repo) + if err is not None: + return err + + headers = {"Accept": MANIFEST_ACCEPT} + if token: + headers["Authorization"] = f"Bearer {token}" + status, resp_headers = _curl_headers(f"{api_base}/v2/{repo}/manifests/{reference}", headers) + if status is None: + return { + "ok": False, "skipped": False, "network_error": True, + "reason": f"could not reach {api_base} resolving {image_ref!r}", + } + if status == 404: + return { + "ok": False, "skipped": False, "network_error": False, + "reason": f"{image_ref}: HTTP 404 — repository or tag does not exist", + } + if status != 200: + return { + "ok": False, "skipped": False, "network_error": True, + "reason": f"registry returned HTTP {status} resolving {image_ref!r}", + } + digest = resp_headers.get("docker-content-digest") + if not digest: + return { + "ok": False, "skipped": False, "network_error": True, + "reason": ( + f"{image_ref}: registry served the manifest without a " + "Docker-Content-Digest header, so there is nothing authoritative to pin to" + ), + } + return {"ok": True, "digest": digest} + + def _parse_ref(image_ref: str) -> tuple[str | None, str, str]: - """Split an image reference into (registry_host_or_None, repo, tag). + """Split an image reference into (registry_host_or_None, repo, reference). registry_host is None for Docker Hub (bare `user/repo` or official - `repo`, both normalized to Docker Hub's `library/` namespace). + `repo`, both normalized to Docker Hub's `library/` namespace). For any + other registry it is the bare host, and `repo` has that host stripped — + that is the form both the /v2/ URL path and the token scope take, so + leaving it on produces 404s on every request. + + `reference` is whatever the registry accepts after /manifests/: a tag, + or a digest when the ref is pinned. Modules pin with the readable + `repo:tag@sha256:…` form, where the tag is decorative and the digest + wins; the tag must be dropped here or it stays glued to the repo path. """ if "@" in image_ref: - # digest reference (repo@sha256:...) — not used by any module today, - # treat the whole thing after '@' as an opaque tag-equivalent. repo_part, tag = image_ref.split("@", 1) + # `repo:tag@sha256:…` — discard the decorative tag, keep the digest. + if ":" in repo_part.rsplit("/", 1)[-1]: + repo_part = repo_part.rsplit(":", 1)[0] elif ":" in image_ref.rsplit("/", 1)[-1]: repo_part, tag = image_ref.rsplit(":", 1) else: @@ -206,7 +388,11 @@ def _parse_ref(image_ref: str) -> tuple[str | None, str, str]: first_segment = repo_part.split("/", 1)[0] is_custom_registry = "." in first_segment or ":" in first_segment or first_segment == "localhost" if is_custom_registry: - return first_segment, repo_part, tag + # A host with no repository path after it is not a usable reference; + # hand it back whole so the caller reports it rather than IndexError. + if "/" not in repo_part: + return first_segment, repo_part, tag + return first_segment, repo_part.split("/", 1)[1], tag if "/" not in repo_part: repo_part = f"library/{repo_part}" @@ -222,54 +408,28 @@ def fetch_image_config(image_ref: str) -> dict[str, Any]: {"ok": False, "skipped": bool, "network_error": bool, "reason": str} """ registry_host, repo, tag = _parse_ref(image_ref) - if registry_host is not None: - return { - "ok": False, - "skipped": True, - "network_error": False, - "reason": ( - f"registry {registry_host!r} is not Docker Hub — this validator only " - "knows Docker Hub's anonymous token flow; skipping (not a failure)" - ), - } - - token_url = ( - f"https://auth.docker.io/token?service=registry.docker.io" - f"&scope=repository:{repo}:pull" - ) - status, token_body, raw = _curl_json(token_url) - if status is None: - return { - "ok": False, - "skipped": False, - "network_error": True, - "reason": f"could not reach auth.docker.io ({raw[:200]!r})", - } - if status != 200 or not token_body or "token" not in token_body: - return { - "ok": False, - "skipped": False, - "network_error": True, - "reason": f"auth.docker.io returned HTTP {status}", - } - token = token_body["token"] - headers = {"Authorization": f"Bearer {token}", "Accept": MANIFEST_ACCEPT} - - manifest_url = f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}" + token, api_base, err = _registry_token(registry_host, repo) + if err is not None: + return err + headers = {"Accept": MANIFEST_ACCEPT} + if token: + headers["Authorization"] = f"Bearer {token}" + + manifest_url = f"{api_base}/v2/{repo}/manifests/{tag}" status, manifest, raw = _curl_json(manifest_url, headers) if status is None: return { "ok": False, "skipped": False, "network_error": True, - "reason": f"could not reach registry-1.docker.io ({raw[:200]!r})", + "reason": f"could not reach {api_base} ({raw[:200]!r})", } if status == 404: return { "ok": False, "skipped": False, "network_error": False, - "reason": f"{image_ref}: HTTP 404 — repository or tag does not exist on Docker Hub", + "reason": f"{image_ref}: HTTP 404 — repository or tag does not exist", } if status != 200 or manifest is None: return { @@ -297,14 +457,14 @@ def fetch_image_config(image_ref: str) -> dict[str, Any]: } digest = chosen["digest"] status, manifest, raw = _curl_json( - f"https://registry-1.docker.io/v2/{repo}/manifests/{digest}", headers + f"{api_base}/v2/{repo}/manifests/{digest}", headers ) if status is None: return { "ok": False, "skipped": False, "network_error": True, - "reason": f"could not reach registry-1.docker.io resolving platform manifest ({raw[:200]!r})", + "reason": f"could not reach {api_base} resolving platform manifest ({raw[:200]!r})", } if status != 200 or manifest is None: return { @@ -323,16 +483,18 @@ def fetch_image_config(image_ref: str) -> dict[str, Any]: "network_error": False, "reason": f"{image_ref}: manifest has no config blob reference", } + blob_headers = {"Accept": "*/*"} + if token: + blob_headers["Authorization"] = f"Bearer {token}" status, config, raw = _curl_json( - f"https://registry-1.docker.io/v2/{repo}/blobs/{config_digest}", - {"Authorization": f"Bearer {token}", "Accept": "*/*"}, + f"{api_base}/v2/{repo}/blobs/{config_digest}", blob_headers ) if status is None: return { "ok": False, "skipped": False, "network_error": True, - "reason": f"could not reach registry-1.docker.io fetching the config blob ({raw[:200]!r})", + "reason": f"could not reach {api_base} fetching the config blob ({raw[:200]!r})", } if status != 200 or config is None: return { @@ -624,6 +786,76 @@ def rule_image_exists(image_ref: str, result: dict) -> list[Finding]: return [Finding(ERROR, "image-not-found", result["reason"])] +def rule_images_pinned(spec: dict) -> list[Finding]: + """Rule 10: whatever a user gets WITHOUT choosing must be pinned by digest. + + A floating tag means a pod restart can swap the game binary under a live + world with no version bump and no changelog. This is not hypothetical: + `passivelemon/terraria-docker:terraria-latest` moved from Terraria 1.4.4.9 + to 1.4.5.6, changed the network protocol, and broke the e2e Terraria bot. + + The line this rule draws is between defaults and choices: + + - ERROR on an unpinned `spec.image`, and on an unpinned version entry + marked `default: true`. Between them these are what a server runs when + the user expressed no preference, so they must be immutable. + - WARN on any other unpinned version entry. A module may legitimately + offer a moving channel (factorio's "Experimental", valheim's "Public + Test") — selecting one is the user's explicit, labelled choice, which + is exactly the escape hatch the roadmap asked for. The WARN keeps them + visible in CI output without blocking them. + + This check is pure string inspection with no registry call, so a network + blip can never downgrade it to "couldn't verify" the way the + image-exists rules degrade. + """ + findings: list[Finding] = [] + + def pinned(ref: str) -> bool: + return "@sha256:" in ref + + image = spec.get("image") + if image and not pinned(image): + findings.append( + Finding( + ERROR, + "unpinned-default-image", + f"spec.image={image!r} floats. A server that selects no version runs " + "this image, so an upstream retag silently changes the game binary. " + "Pin it as repo:tag@sha256:… (run `python3 validate.py --pin`); keep a " + "moving tag only in a labelled spec.versions entry.", + ) + ) + + for v in spec.get("versions") or []: + ref = v.get("image") + if not ref or pinned(ref): + continue + vid = v.get("id", "") + if v.get("default"): + findings.append( + Finding( + ERROR, + "unpinned-default-version", + f"spec.versions[{vid}] is marked default: true but its image " + f"{ref!r} floats — this is what a new server gets without the user " + "choosing. Pin it (`python3 validate.py --pin`).", + ) + ) + else: + findings.append( + Finding( + WARN, + "unpinned-version-image", + f"spec.versions[{vid}] image {ref!r} floats. That is allowed for a " + "deliberately-moving channel the user picks by name; if this entry " + "is meant to identify a specific build, pin it.", + ) + ) + + return findings + + def rule_rcon_protocol(spec: dict) -> list[Finding]: """Rule 6: rcon.protocol must be one the agent actually implements.""" rcon = spec.get("rcon") @@ -840,9 +1072,111 @@ def validate_module(spec: dict, cache: dict[str, dict]) -> list[Finding]: findings += rule_rcon_protocol(spec) findings += rule_mods_loaders_requires_versions(spec) findings += rule_credential_fields_must_be_password(spec) + findings += rule_images_pinned(spec) return findings +# -------------------------------------------------------------------------- +# Pin mode (`--pin`): rewrite every declared image to repo:tag@sha256:… +# -------------------------------------------------------------------------- + +# Matches a YAML `image:` value at any indent, with or without a leading +# sequence dash, capturing any trailing comment so it survives the rewrite. +_IMAGE_LINE_RE = re.compile(r"^(\s*(?:-\s+)?image:\s*)(\S+)(\s*(?:#.*)?)$") + +# Opt-out marker: an image line carrying this trailing comment is left +# floating by pin mode. It exists because a module may deliberately offer a +# moving channel the user picks by name -- factorio's "Experimental", +# terraria's tmodloader pre-release -- and freezing those would defeat the +# entry's whole purpose. A comment rather than a schema field keeps this +# codegen-free: no CRD change, no regenerated manifests, and rule_images_pinned +# already tolerates an unpinned non-default version entry. +FLOATING_MARKER = "# gameplane:floating" + + +def _unpinned(image_ref: str) -> str: + """Strip any digest, leaving repo:tag — what a refresh must re-resolve. + + Resolving an already-pinned ref just returns the digest it already + carries, so re-pinning has to go back to the tag to pick up new upstream + builds. A ref pinned without a tag can only be refreshed as :latest, + which is why pin mode always writes the readable repo:tag@digest form. + """ + if "@" not in image_ref: + return image_ref + repo_part = image_ref.split("@", 1)[0] + return repo_part if ":" in repo_part.rsplit("/", 1)[-1] else f"{repo_part}:latest" + + +def pin_templates(module_dirs: list[Path]) -> int: + """Rewrite each template.yaml's image refs to pinned digests, in place. + + Deliberately line-based rather than a YAML round-trip: these templates + carry substantial explanatory comments and deliberate formatting, and + yaml.safe_load + yaml.dump would silently discard all of it. + """ + digest_cache: dict[str, dict] = {} + changed_files = 0 + failures: list[str] = [] + + for module_dir in module_dirs: + template_path = module_dir / "template.yaml" + if not template_path.exists(): + continue + + lines = template_path.read_text().splitlines(keepends=True) + out: list[str] = [] + edits: list[str] = [] + + for line in lines: + m = _IMAGE_LINE_RE.match(line.rstrip("\n")) + if not m: + out.append(line) + continue + prefix, ref, suffix = m.groups() + if FLOATING_MARKER in suffix: + out.append(line) + continue + target = _unpinned(ref.strip("\"'")) + + if target not in digest_cache: + digest_cache[target] = resolve_digest(target) + result = digest_cache[target] + + if not result.get("ok"): + failures.append(f"{module_dir.name}: {target} — {result['reason']}") + out.append(line) + continue + + newref = f"{target}@{result['digest']}" + if newref == ref.strip("\"'"): + out.append(line) + continue + newline = f"{prefix}{newref}{suffix}" + out.append(newline + ("\n" if line.endswith("\n") else "")) + edits.append(f" {ref}\n -> {newref}") + + if edits: + template_path.write_text("".join(out)) + changed_files += 1 + print(f"== {module_dir.name} == ({len(edits)} ref(s) pinned)") + for e in edits: + print(e) + else: + print(f"== {module_dir.name} == already pinned / no change") + + print("\n---") + print(f"PIN SUMMARY: {changed_files} template(s) rewritten.") + if failures: + # A ref that could not be resolved is left exactly as it was, so a + # partial run never writes a half-pinned or invented digest. + print(f"{len(failures)} ref(s) could NOT be resolved and were left untouched:") + for f in failures: + print(f" {f}") + return 1 + return 0 + + def discover_modules(root: Path, names: list[str] | None) -> list[Path]: if names: return [root / n for n in names] @@ -855,9 +1189,19 @@ def discover_modules(root: Path, names: list[str] | None) -> list[Path]: def main(argv: list[str]) -> int: root = Path(__file__).resolve().parent - names = argv[1:] or None + args = argv[1:] + + # `--pin` is a maintenance/codegen mode, not a check: it rewrites the + # templates rather than reporting on them. Kept as a flag on this script + # (rather than a separate tool) so it shares one registry-access + # implementation with the rules that enforce its output. + pin_mode = "--pin" in args + names = [a for a in args if not a.startswith("-")] or None module_dirs = discover_modules(root, names) + if pin_mode: + return pin_templates(module_dirs) + cache: dict[str, dict] = {} any_error = False any_module = False