Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a192ede2a | ||
|
|
06d4a9a1fc | ||
|
|
9db20e8ca0 | ||
|
|
ddd04d460e | ||
|
|
f0760d1024 | ||
|
|
553d027f7c | ||
|
|
183bca6897 | ||
|
|
ce849032d5 | ||
|
|
b766130b85 | ||
|
|
862773f31b | ||
|
|
db4ba79a1c | ||
|
|
0b6282aba4 | ||
|
|
c6cfaf62b6 | ||
|
|
4ae648f6d3 | ||
|
|
a4d5206294 | ||
|
|
47eb6afa02 | ||
|
|
74ba90c0b3 | ||
|
|
fb45d7441b | ||
|
|
df96be98bf | ||
|
|
7f0cbc7ba2 | ||
|
|
a7eadd4054 |
No files matched your search
@@ -80,7 +80,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0 # full history so ci/touched-requirements.sh can diff vs main
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0 # the per-image change detection diffs against the base commit
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
spec-immutability:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # the implementation repo
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # the implementation repo
|
||||
- name: Check out the read-only spec (sibling path)
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: ${{ vars.SPEC_REPO_SLUG }} # e.g. Home-Network/home-network-k3s (set in Gitea)
|
||||
path: ../home-network-k3s
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [spec-immutability]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 } # full history so touched-requirement diff works
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh lint
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh l0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [l0]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh l1
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [render-golden]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh l1
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
run: make -C ci l1
|
||||
- name: Upload L1 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: l1-junit-coverage
|
||||
path: |
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
needs: [l1]
|
||||
if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh conformance
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
run: make -C ci conformance
|
||||
- name: Upload L2/L3 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: conformance-junit-coverage
|
||||
path: |
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
needs: [lint, l0, render-golden, l1, conformance]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Decide
|
||||
env:
|
||||
R_LINT: ${{ needs.lint.result }}
|
||||
|
||||
@@ -78,7 +78,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: jackett
|
||||
image: lscr.io/linuxserver/jackett:latest
|
||||
image: lscr.io/linuxserver/jackett:v0.24.2865-ls7
|
||||
ports:
|
||||
- containerPort: 9117
|
||||
volumeMounts:
|
||||
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: pihole
|
||||
image: pihole/pihole
|
||||
image: pihole/pihole:2026.10.0
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
|
||||
@@ -120,7 +120,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: plex
|
||||
image: plexinc/pms-docker:latest
|
||||
image: plexinc/pms-docker:1.43.4.10903-e5521bd8c
|
||||
ports:
|
||||
- containerPort: 32400
|
||||
env:
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
name: jackett
|
||||
kind: container
|
||||
source:
|
||||
image: lscr.io/linuxserver/jackett:latest
|
||||
image: lscr.io/linuxserver/jackett:v0.24.2865-ls7
|
||||
exposure: internal # LAN-only web UI at jackett.${BASE_DOMAIN} (docs/13 row 4; R-DNS/R-NET)
|
||||
egress: vpn # indexer scraping via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
||||
storage:
|
||||
@@ -70,7 +70,8 @@ auth:
|
||||
- ^/api/
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=0.22.0" # stay on the Jackett 0.22.x stable line
|
||||
range: ">=0.22.0" # floor only, no ceiling (0.24.x runs as of 2026-10-30; Jackett has no
|
||||
# major line to hold back from, so renovate.json5 mirrors no cap)
|
||||
cadence: "0 4 * * *"
|
||||
autoRollbackOnFailedHealth: true # R-UPD-03
|
||||
observability:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
name: pihole
|
||||
kind: container
|
||||
source:
|
||||
image: pihole/pihole
|
||||
image: pihole/pihole:2026.10.0
|
||||
exposure: internal # LAN-only admin UI (docs/13 row 6; R-DNS/R-NET)
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
name: plex
|
||||
kind: container
|
||||
source:
|
||||
image: plexinc/pms-docker:latest
|
||||
image: plexinc/pms-docker:1.43.4.10903-e5521bd8c
|
||||
# plexinc/pms-docker env. Rendered VERBATIM onto the app container by render.py (schema env list).
|
||||
env:
|
||||
# PLEX_UID/PLEX_GID: the image boots as root (s6 PID1), chowns /config to this uid/gid, then drops
|
||||
|
||||
@@ -61,7 +61,7 @@ CONFORMANCE_RUNNER_IMAGE_TAG="chainsaw0.2.12-kubectl1.36.4-fc44"
|
||||
# chainsaw-job.yaml's @@CONFORMANCE_RUNNER_IMAGE_DIGEST@@ takes the 64 hex digits WITHOUT the
|
||||
# `sha256:` prefix this value carries — the trigger must strip it.
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/conformance-runner tag=main
|
||||
CONFORMANCE_RUNNER_IMAGE_DIGEST="sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
CONFORMANCE_RUNNER_IMAGE_DIGEST="sha256:65cc7c1ab576fe8cdf45e7f68c7cf4eb5ce7e0db46e62191f6190c51610b20d9"
|
||||
CONFORMANCE_RUNNER_IMAGE="${CONFORMANCE_RUNNER_IMAGE_REPO}@${CONFORMANCE_RUNNER_IMAGE_DIGEST}"
|
||||
#
|
||||
# AFTERWARDS, TIGHTEN THE BOUNDARY: the header of conformance-trigger-job-shape.yaml asks for
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: feat/pm-agent-registry-pull-secret
|
||||
title: The kubelet's pull credential — the second half of making agent-runner pullable, and the seventh field the admission policy had not read
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The registry had two blockers. The first — the nodes resolving the registry hostname to the
|
||||
non-hairpinned WAN and failing TLS against the router's `CN=%D` certificate — was diagnosed
|
||||
and fixed by an `/etc/hosts` pin, merged in #59 and applied by the operator. This is the
|
||||
second: **the pull is unauthenticated, and this registry refuses unauthenticated clients
|
||||
outright.**
|
||||
|
||||
`pm-agent-registry` is a new `kubernetes.io/dockerconfigjson` Secret, SOPS-encrypted with
|
||||
placeholder values, referenced by `imagePullSecrets` on both the dispatcher Deployment and
|
||||
the committed worker Job template. It is a **second Secret holding the same Gitea token** as
|
||||
`pm-agent-credentials`, because the two consumers need two formats: the application reads a
|
||||
raw token from env, the kubelet reads a dockerconfigjson before any container starts. That
|
||||
duplication is the established pattern here (the Keycloak client secret is mirrored the same
|
||||
way, for the same reason) and it is documented in both `.example` files.
|
||||
|
||||
The operator's part is the value, and one scope: the Gitea token must now carry
|
||||
**`read:package`** alongside `write:repository` and `write:issue`. Steps are in
|
||||
`platform/pm-agent/pm-agent-registry.sops.yaml.example`, including the trap that
|
||||
`.dockerconfigjson`'s registry key must be the bare host as the image reference spells it —
|
||||
with a scheme or a port it matches nothing, the kubelet pulls anonymously, and the failure is
|
||||
indistinguishable from the Secret being absent.
|
||||
|
||||
**The admission policy gained a seventh rule.** `imagePullSecrets` is a pod-spec field that
|
||||
no container references, which is exactly why it is easy to leave unread — the same shape of
|
||||
omission as `initContainers`, `ephemeralContainers`, `env` and `envFrom` before it. The
|
||||
kubelet reads the named Secret, in this namespace, before anything runs. Rule 1's pattern now
|
||||
REQUIRES the committed entry and rule 7 forbids any other or any second one; both halves are
|
||||
needed because a `foreach` over an absent list yields no result.
|
||||
|
||||
The leverage is small and worth saying so: the image is digest-pinned, so a different pull
|
||||
credential cannot change *what* runs, and the only Secrets in this namespace are the agent's
|
||||
own. It is pinned anyway, because "only the committed shape" is this policy's whole premise
|
||||
and the rule costs four lines.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **`kyverno test platform/kyverno/tests/pm-agent` → 33 passed, 0 failed** under the pinned
|
||||
CLI (was 28 before this change). Three new fixtures, each a real path rather than a style
|
||||
violation: `extra-pull-secret` (the committed entry PLUS a second one — rule 1's array
|
||||
pattern is satisfied by the first element, which is why the count is asserted separately),
|
||||
`wrong-pull-secret` (a different Secret in this namespace), and `no-pull-secret` (absent
|
||||
entirely, refused by rule 1's pattern because rule 7's foreach cannot see it).
|
||||
- **`PM_SELFTEST=1 pm-dispatch` → exit 0**, now also asserting that the committed Job
|
||||
template names exactly `pm-agent-registry` — so the template and the policy cannot drift
|
||||
apart without the image build failing.
|
||||
- **`./ci/gate.sh` in the pinned toolbox, from a §0 worktree → exit 3 (UNDETERMINED)**, with
|
||||
`gitea-settings` the only stage that did not run (no `SETTINGS_API_TOKEN` on this
|
||||
workstation). Every other stage green, `spec-immutability` included. rc 3 is not a pass.
|
||||
- **The gate caught me shipping the Secret in plaintext.** I wrote the placeholder
|
||||
`pm-agent-registry.sops.yaml` and never ran `sops -e -i` on it. `no-plaintext-secrets`
|
||||
refused it with both halves named — "has no top-level `sops:` block with a mac" and
|
||||
"Secret value '.dockerconfigjson' is not SOPS ciphertext" — which is exactly the check
|
||||
earning its place: the file looked finished, and the value was a placeholder, so nothing
|
||||
about reading the diff would have made it obvious. Encrypted, and the stage is green.
|
||||
- **The registry really does refuse anonymous clients**, measured before building this
|
||||
(2026-10-05): the anonymous registry token's `access` claim is `null`, every `/v2/…`
|
||||
path answers 401 whether it exists or not, and `/api/v1/packages/Home-Network` replies
|
||||
`{"message":"token is required"}`. That is why this is a credential and not a posture
|
||||
tweak.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the pull now works.** Nothing has been pushed to that registry and the committed
|
||||
digest is still the all-zero placeholder, so neither half of the registry fix has been
|
||||
exercised end to end. The first real pull is what proves both at once — and if it fails, the
|
||||
error names which half: `x509 … not valid for any names` is the resolution pin, `401` /
|
||||
`unauthorized` is this credential.
|
||||
- **That the `/etc/hosts` pin from #59 took effect.** The operator applied it; I could not
|
||||
confirm the effect, because the dispatcher pod's retained events are all repeating
|
||||
`BackOff` with no fresh `Pulling`, so the `x509` message still visible is the one from
|
||||
before the play ran. Not evidence either way.
|
||||
- **Whether `read:package` is sufficient** for a pull from a Gitea package of this type. It is
|
||||
the documented read scope for packages; nothing here has exercised it, because no package
|
||||
exists yet.
|
||||
- **The dockerconfigjson placeholder is not a valid credential and is not meant to be.** Its
|
||||
`auth` field is a `REPLACE-ME` string rather than base64, so a pull with it will fail at
|
||||
authentication rather than be mistaken for working.
|
||||
- **Anything about runtime.** Unchanged from the previous entries: the image has never been
|
||||
built or pushed, no dispatcher has authenticated to Gitea, no issue has been triaged, no
|
||||
worker Job has ever been created, and the admission policy — live, `Enforce`, now seven
|
||||
rules — has still never admitted or refused a real Job.
|
||||
|
||||
**Addendum 2026-10-30 (rebased onto #81/#82/#83; see `decisions/2026-10-30-pm-agent-live.md`).**
|
||||
Three statements above are now out of date, and one was wrong when written:
|
||||
- *"Nothing has been pushed to that registry"* — no longer true. CI published agent-runner in
|
||||
images run 340 (`sha-523cd9118f95` = `main`, digest `sha256:c4eba5b3…6eb42`), and this
|
||||
branch now pins it.
|
||||
- *"That the `/etc/hosts` pin from #59 took effect"* — now measured: all three prod nodes
|
||||
carry the line, resolve the name to `192.168.2.20`, and get `401` with `ssl_verify_result=0`
|
||||
from `https://gitea.home.keithjackson.dev/v2/` (see the new entry).
|
||||
- *"Whether `read:package` is sufficient"* — the scope is necessary but not the whole answer:
|
||||
org-package access is decided by account permission (team Packages unit, or org
|
||||
visibility). Handled in the registry `.example`.
|
||||
- **Wrong when written:** the scope list. Gitea 1.24.3 puts `GET /api/v1/user` — the
|
||||
dispatcher's first call — behind the `user` scope category, so a token with only
|
||||
`write:repository`, `write:issue` and `read:package` CrashLoops the dispatcher on startup.
|
||||
The token needs `read:user` too.
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: feat/pin-pihole-jackett-plex
|
||||
title: Pin pihole, jackett and plex to the digests already running, and make Renovate able to follow them
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
- **`catalog/{pihole,jackett,plex}.yaml` `source.image`** pinned to the tag whose digest was
|
||||
already running, so the *image* does not change:
|
||||
- `pihole/pihole` (no tag) -> `pihole/pihole:2026.10.0`
|
||||
- `lscr.io/linuxserver/jackett:latest` -> `lscr.io/linuxserver/jackett:v0.24.2865-ls7`
|
||||
- `plexinc/pms-docker:latest` -> `plexinc/pms-docker:1.43.4.10903-e5521bd8c`
|
||||
- **`apps/wave-0/{pihole,jackett,plex}.yaml`** re-rendered (`render.py --all catalog -o apps
|
||||
--wave-layout --no-image-automation`, in the toolbox). The diff is exactly the three app-container
|
||||
`image:` lines; nothing else moved.
|
||||
- **`catalog/jackett.yaml`** `update.range` comment: "stay on the Jackett 0.22.x stable line" was
|
||||
false (0.24 runs, and `>=0.22.0` has no ceiling). Range unchanged; comment corrected.
|
||||
- **`renovate.json5`**, four packageRules plus a comment block:
|
||||
- jackett `versioning: regex:^v(major).(minor).(patch)-ls(build)$` (Renovate's linuxserver example).
|
||||
- jackett **`overridePackageName: ghcr.io/linuxserver/jackett`**: not in the brief, and needed.
|
||||
Renovate's docker datasource reads at most `dockerMaxPages` (20, a global-only option) tag
|
||||
pages from any host except a short list that includes ghcr.io (1000 pages). lscr.io is not on
|
||||
that list. Jackett has **32,093** tags, oldest first, ~1000 per page; the running tag is #31,980.
|
||||
Before this rule the dry run extracted jackett at `v0.24.2865-ls7` and **proposed nothing**,
|
||||
with no warning. The override changes only the lookup; the manifests keep `lscr.io/...`.
|
||||
Every jackett rule matches on `depName`, because the override rewrites `packageName`.
|
||||
- jackett `schedule: after 3am and before 6am on sunday`, i.e. weekly. Measured: 13 ls builds in
|
||||
12 days (ls7 built 2026-10-17, ls20 built 2026-10-29). Each is a `patch` and auto-merges, so on
|
||||
the daily window it would mean a PR, a gate run and a jackett restart (Recreate) every day.
|
||||
Weekly picks the newest tag, so it skips nothing. `catalog/jackett.yaml update.cadence` still
|
||||
says daily. That field fed the retired Flux image-automation, and the rule's description
|
||||
says the rule is now the cadence that takes effect.
|
||||
- plex `versioning: regex:^(major).(minor).(patch).(build)-[0-9a-f]+$`.
|
||||
- pihole: **no rule**, as the operator decided. CalVer parses under default docker versioning.
|
||||
Patches and minors auto-merge, and a year rollover is a major (dashboard approval). This is
|
||||
written in a comment.
|
||||
- Corrected the "WHAT RENOVATE STILL CANNOT FIX" comment. It listed these three as floating;
|
||||
they publish immutable version tags.
|
||||
- No `allowedVersions` mirror was added. None of the three catalog ranges has a ceiling
|
||||
(`>=2024.0.0`, `>=0.22.0`, `>=1.40.0`), so #86's mirror has nothing to encode for them.
|
||||
- **Runbooks:** `image-policy-stalled.md` no longer says jackett and pms-docker are
|
||||
`latest`-only. In `plex-quicksync.md`, "the floating-image caveat" became "the image-version
|
||||
caveat", because the deployment no longer runs `:latest` with an implicit `Always`, and the symptom
|
||||
is now "after a pms-docker bump". `plex-claim.md`'s two "running `:latest`" mentions are
|
||||
now in the past tense.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Live digests** (read-only `kubectl --context k3s-prod`, pod `.status.containerStatuses[].imageID`):
|
||||
pihole-0 and pihole-1 `sha256:d34134716fc3…`, jackett `sha256:4fcf3cc22dd3…`, plex
|
||||
`sha256:e0ab27395614…`.
|
||||
- **Each chosen tag resolves to the same digest** (anonymous registry HEAD, OCI index + manifest-list
|
||||
Accept): `pihole/pihole:2026.10.0` = `sha256:d34134716fc3…`; `ghcr.io/linuxserver/jackett:v0.24.2865-ls7`
|
||||
= `sha256:4fcf3cc22dd3…`; `plexinc/pms-docker:1.43.4.10903-e5521bd8c` = `sha256:e0ab27395614…`.
|
||||
(For contrast, `pms-docker:latest` is now `sha256:6d778d41…` and `jackett:latest` `sha256:ee6ae915…`.
|
||||
The pods would have drifted on their next restart.)
|
||||
- `renovate-config-validator` (ghcr.io/renovatebot/renovate:44.167.0) reports "Config validated
|
||||
successfully". The only warning is the existing `docker` block migration.
|
||||
- **Renovate dry run** (`RENOVATE_PLATFORM=local`, `--dry-run=full`, 44.167.0, anonymous, includePaths
|
||||
limited to the six files). It extracted all three from both `catalog/` (custom regex) and
|
||||
`apps/wave-0/` (kubernetes) under the same depName, so each lands in one branch:
|
||||
- plex `1.43.4.10903-e5521bd8c` -> `1.43.5.11079-b3b13e506` (patch, `renovate/plexinc-pms-docker-1.x`)
|
||||
- jackett `v0.24.2865-ls7` -> `v0.24.2961-ls20` (patch, `renovate/lscr.io-linuxserver-jackett-0.x`).
|
||||
This needs the ghcr override; without it there was no proposal.
|
||||
- pihole `2026.10.0` -> none. That is correct, because 2026.10.0 is the newest numbered tag on Docker Hub.
|
||||
- **Automerge, schedule and dashboard flags** come from Renovate's own `applyPackageRules`
|
||||
(datasource-merge, then update-type-merge, the same order as `flatten.js`), run on the resolved
|
||||
config. The local platform does not run the branch worker:
|
||||
- plex patch: automerge **true**, daily window.
|
||||
- jackett patch: automerge **true**, schedule **sunday** window.
|
||||
- pihole (synthetic updates) `2026.10.1` patch and `2026.11.0` minor: automerge **true**. `2027.01.0`
|
||||
major: automerge **false**, `dependencyDashboardApproval` **true**, +needs-review/breaking.
|
||||
- **Versioning filters** (Renovate's versioning module): the jackett regex rejects `latest`, bare
|
||||
`0.24.2961`, `amd64-…` and `development-…`. The plex regex rejects `latest`, `public`, `beta`,
|
||||
`plexpass` and `…-amd64`/`…-arm64`. Docker versioning ranks `2026.09.0 < 2026.10.0`.
|
||||
- Touched paths (`catalog/`, `apps/`, `renovate.json5`, `runbooks/`, `decisions/`) are not in
|
||||
`infra/gitea/approval-policy.json` `sensitive_paths`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the merge is restart-free. It is not.** The pod-template `image` string changes, so Flux
|
||||
rolls all three: plex and jackett are `Recreate`, so each has a brief outage, and pihole is a
|
||||
2-replica StatefulSet that rolls one pod at a time. The digest is identical, so the image's
|
||||
behaviour should not change, but the kubelet resolves the new tag name and may pull it. The
|
||||
rollout itself was not observed.
|
||||
- The implicit `imagePullPolicy` moves from `Always` (`:latest` or no tag) to `IfNotPresent`. This is
|
||||
intended, and was not observed live.
|
||||
- A real (non-dry) Renovate run against Gitea, and whether the Sunday window actually opens a jackett
|
||||
PR. The schedule was only evaluated, not exercised.
|
||||
- Other `lscr.io/linuxserver/*` images (radarr, sonarr, qbittorrent) may hit the same 20-page
|
||||
truncation once their tag counts grow. Not measured here, and not changed.
|
||||
- Plex not self-updating on a version tag was checked only against the running pod (read-only
|
||||
`kubectl exec cat`). `/etc/cont-init.d/50-plex-update` does `exit 0` when `version` from
|
||||
`/version.txt` equals the installed `plexmediaserver` package. Both are
|
||||
`1.43.4.10903-e5521bd8c`. I did not boot a pod on a *new* tag to watch that short-circuit.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: feat/pm-agent-registry-pull-secret
|
||||
title: PM agent — #61 rebased onto the CI image pipeline, first published digest pinned, and the remaining path to live audited
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
PR #61 (`feat/pm-agent-registry-pull-secret`, the kubelet pull credential and admission rule
|
||||
7) was rebased onto `origin/main` after #81 (CI image pipeline), #82 and #83. Conflicts in
|
||||
`docs/plans/pm-agent-issue-to-pr.md`, `platform/pm-agent/README.md` and
|
||||
`runbooks/pm-agent.md` were resolved keeping both sides: #61's "pull credential now wired"
|
||||
and #81's "CI publishes, Renovate bumps". No manual build-and-push text came back.
|
||||
|
||||
On top of the rebase, as a second commit:
|
||||
|
||||
- **The first published digest is pinned**: `deployment.yaml` and `PM_WORKER_IMAGE` in
|
||||
`configmap.yaml` now name
|
||||
`gitea.home.keithjackson.dev/home-network/agent-runner@sha256:c4eba5b3c508bf456a03391ac76f8275e7ea28a829da1450d99f6f6f25c6eb42`
|
||||
(images run 340, tag `sha-523cd9118f95`, which is also `main`). The `# renovate-digest:`
|
||||
markers are unchanged and still sit on the line directly above each value, so later bumps
|
||||
stay Renovate's. Pinning by hand once removes the dependency on Renovate's first-ever run.
|
||||
- **The first run is dry, and scoped to a label**: `PM_DRY_RUN: "true"`,
|
||||
`PM_REQUIRE_LABEL: "agent"` (the plan's follow-up 3); `config-generation` bumped to `2`.
|
||||
Dry run is also a *correctness* requirement, not only caution: the pinned digest was
|
||||
built before this PR added `imagePullSecrets` to the worker Job template, which is baked
|
||||
into the image. Admission now requires that entry, so a worker Job built from the run-340
|
||||
image would be denied. Merging this PR touches `infra/agent/**`, so `images.yaml`
|
||||
re-publishes agent-runner; Renovate's bump to that digest is what makes workers
|
||||
admissible. Only then flip `PM_DRY_RUN` to `false`.
|
||||
- **The token scope list was wrong; it is fixed.** Gitea 1.24.3's `routers/api/v1/api.go`
|
||||
closes the `/user` route group with `tokenRequiresScopes(AccessTokenScopeCategoryUser)`,
|
||||
and the dispatcher's first call is `GET /user`. The documented scopes are now
|
||||
`read:user`, `write:repository`, `write:issue`, `read:package` everywhere (both
|
||||
`.example`s, README, runbook row).
|
||||
- **Org-package permission is documented separately from the scope.** Per
|
||||
`services/context/package.go` `determineAccessMode` (v1.24.3), a non-admin's access to an
|
||||
org-owned package is the highest Packages-unit mode across its org teams, else read if the
|
||||
org is visible to it. Home-Network is `public`, so it works today by visibility alone; the
|
||||
registry `.example` now has the operator create a `pm-agent-pull` team (no repositories,
|
||||
Packages = Read) so it does not depend on that.
|
||||
- **`sops` is toolbox-only**: both `.example`s now say so and give the in-place
|
||||
`./infra/devcontainer/dev.sh run sops <file>` edit (the same procedure
|
||||
`runbooks/vpn-remote-access.md` documents), which writes no plaintext file.
|
||||
- The runbook's Secret-key check used `-o jsonpath='{.data}'`, which prints values before the
|
||||
pipe trims them; it is now a `go-template` that emits key names only. Two new runbook
|
||||
rows: the `read:user` 403, and the admission denial from a pre-#61 digest.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Registry digest**, read-only from the registry API (bearer token from `/v2/token`,
|
||||
`HEAD /v2/home-network/agent-runner/manifests/<tag>` with OCI and Docker accept headers):
|
||||
`main` and `sha-523cd9118f95` both answer `200`,
|
||||
`Docker-Content-Digest: sha256:c4eba5b3…6eb42`, `application/vnd.oci.image.manifest.v1+json`.
|
||||
Tags list: `[main, sha-523cd9118f95]`. The admission pattern
|
||||
`gitea.home.keithjackson.dev/home-network/agent-runner@sha256:*` matches the pinned value.
|
||||
- **#81's claim that the template's digest needs no tracking holds**: `pm-dispatch.build_job`
|
||||
sets `containers[0].image = cfg.worker_image` (from `PM_WORKER_IMAGE`) on every Job.
|
||||
Renovate's `renovate-digest` custom manager only matches `platform/pm-agent/*.yaml` and
|
||||
`versions.env`, so the template is not touched by it.
|
||||
- **Node path to the registry, read-only over SSH, all three prod nodes**: `/etc/hosts` line 9
|
||||
is `192.168.2.20 keycloak.home.keithjackson.dev gitea.home.keithjackson.dev`, nsswitch is
|
||||
`hosts: files dns`, and `curl https://gitea.home.keithjackson.dev/v2/` from the node answers
|
||||
`401` with `ssl_verify_result=0` and `remote_ip=192.168.2.20`. That is the host resolver,
|
||||
which is what containerd (a node process) uses; a pod's view would not have shown it.
|
||||
- **Network path from the namespace**: dispatcher and worker both use
|
||||
`PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000` (ConfigMap, Job template, and
|
||||
`pm-worker`'s hard allowlist, which refuses anything else); the git remote and the API base
|
||||
are both derived from it. So nothing goes through the Gateway and #83's
|
||||
`toEndpoints`-through-Gateway issue does not apply. Egress: `toEndpoints` gitea pods `:3000`
|
||||
(both CNPs), `kube-apiserver` (dispatcher only), `world:443` (the model). Ingress on the
|
||||
Gitea side: `gitea-allow-pm-agent-ingress` exists live and is `VALID True`.
|
||||
- **Live state (read-only, `--context k3s-prod`)**: namespace `pm-agent` exists; Deployment
|
||||
`0/1`, pod `ImagePullBackOff` for 24d on the all-zero digest; Secret `pm-agent-credentials`
|
||||
(2 keys) exists, `pm-agent-registry` does not yet (it ships in this PR); Flux Kustomization
|
||||
`pm-agent` Ready at `main@dfc432c`; ClusterPolicy `pm-agent-job-shape` Ready.
|
||||
- **Gitea state (read-only API)**: 1.24.3; org Home-Network `public`; no user `pm-agent`
|
||||
exists; teams are `Owners` and `registry-push`; collaborators are `jakman85` (owner) and
|
||||
`renovate-bot` (write); the repository has **no labels** and **no open issues**.
|
||||
- **Placeholders, without decrypting**: the registry ciphertext decodes to 155 bytes, exactly
|
||||
the length of the `.example`'s placeholder JSON. The credentials values decode to 57 and 39
|
||||
bytes — neither a 40-char Gitea token nor a ~100-char Claude OAuth token. Both files'
|
||||
`lastmodified` is the commit that created them as placeholders.
|
||||
- `kyverno test`, `PM_SELFTEST=1 pm-dispatch` and the gate: numbers in the PR description and
|
||||
the final handoff report for this run.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Any authenticated pull, poll or worker.** No `pm-agent` account or token exists, so the
|
||||
scope set has not been exercised end to end. The `read:user` requirement is read from
|
||||
Gitea's v1.24.3 source, not from a live 403.
|
||||
- **That the credentials ciphertext is a placeholder.** Lengths are strong evidence, not
|
||||
proof; I did not decrypt.
|
||||
- **That Renovate will open the post-merge bump.** Its token was being re-minted at the time
|
||||
of writing and it has never completed a run here. Renovate's `kubernetes` manager also
|
||||
scans `infra/agent/*.yaml` and `platform/**`; what it does with a digest-only reference
|
||||
with no tag (the template) is unmeasured.
|
||||
- **The dispatcher's `claude --print` call to `api.anthropic.com`** through `world:443`, and
|
||||
whether the run-340 image's dispatcher behaves as this tree's code does (it predates #61's
|
||||
selftest line, nothing else in `pm-dispatch.py`).
|
||||
- **The admission policy against a real Job.** Still never admitted or refused one live.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: fix/renovate-hold-auth-and-self
|
||||
title: Operator decision — the oauth2-proxy sidecar and Renovate's own version never auto-merge
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
`renovate.json5` gains two packageRules. Both set `automerge: false` and `addLabels: ["needs-review"]`.
|
||||
They sit after the auto-merge rule, so they override it at every update type. Each rule's
|
||||
`description` carries the operator's reason.
|
||||
|
||||
1. **`quay.io/oauth2-proxy/oauth2-proxy`.** This is the single depName emitted for:
|
||||
- `catalog/render.py` `OAUTH2_PROXY_IMAGE` (custom regex manager);
|
||||
- the five rendered `apps/wave-*` sidecars;
|
||||
- `platform/crafty/deployment.yaml` (kubernetes manager).
|
||||
|
||||
**Reason:** since v7.13.0, oauth2-proxy validates refreshed OIDC sessions against the access
|
||||
token, not the id_token. Every sidecar sets `--cookie-refresh`, and that path is untested against
|
||||
this Keycloak: the platform oauth2-proxy runs v7.15.5 without `--cookie-refresh`.
|
||||
`render-golden` and `--selftest` pass on the bump, but both check rendered text, not the binary.
|
||||
2. **`ghcr.io/renovatebot/renovate`.** This is the job `container.image` in
|
||||
`.gitea/workflows/renovate.yaml`. The rule also lists `renovatebot/github-action`, so a
|
||||
reintroduction would be held too. It is not used anywhere today; `ci/lint.sh` rejects it.
|
||||
|
||||
**Reason:** nothing re-validates `renovate.json5` against the new version before it takes
|
||||
effect. The 39.x `CONFIG_VALIDATION` failure is the precedent.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The config validates on the pinned 44.167.0.** `renovate-config-validator` printed
|
||||
`Config validated successfully`. The migration diff is only the pre-existing top-level `docker`
|
||||
block.
|
||||
- **The flag change is exactly the intended one.** I used Renovate 44.167.0's own
|
||||
`resolveConfigPresets` and `applyPackageRules` to evaluate b4ff287's config and this branch's
|
||||
config against the same updates (the local dry run of the #86 config). Exactly **2 of 29**
|
||||
branch rows differ:
|
||||
- `ghcr.io-renovatebot-renovate-44.x`: `automerge=true labels=` → `automerge=false labels=needs-review`;
|
||||
- `quay.io-oauth2-proxy-oauth2-proxy-7.x`: the same change, and every upgrade in the branch
|
||||
resolves `false`, the `catalog/render.py` one included.
|
||||
|
||||
The other 27 rows are byte-identical.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The three GitHub-hosted branches** in run 357 (`pin-dependencies`, `kubevirt-cdi`,
|
||||
`intel-intel-device-plugins-for-kubernetes-0.x`) are not in the harness table: my local run has
|
||||
no github.com token. Neither rule's package names can match them.
|
||||
- **A live Renovate run with this config.** The next debug-level dispatch should show
|
||||
`needs-review` on both branches.
|
||||
- **The oauth2-proxy refresh path itself** remains untested. Exercising it on one app is the
|
||||
review the label asks for.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
date: 2026-10-31
|
||||
branch: chore/pm-agent-runner-digest
|
||||
title: Bump pm-agent to the run-383 agent-runner digest by hand; Renovate saw it but the schedule withheld it
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
`platform/pm-agent/deployment.yaml` (the dispatcher `image:`) and `platform/pm-agent/configmap.yaml`
|
||||
(`PM_WORKER_IMAGE`) move:
|
||||
|
||||
- from `agent-runner@sha256:c4eba5b3c508bf456a03391ac76f8275e7ea28a829da1450d99f6f6f25c6eb42`, the
|
||||
run-340 publish;
|
||||
- to `agent-runner@sha256:df36d9b7fef56d141220a387f64fb864cede8e5af30513ebfeb3ab566d3f771d`, the
|
||||
images run 383 publish of ddd04d4.
|
||||
|
||||
Both `# renovate-digest:` markers are unchanged, directly above their values.
|
||||
|
||||
`pm-agent.homelab.keithjackson.dev/config-generation` goes `"2"` → `"3"`. That is the convention in
|
||||
`deployment.yaml`'s own comment: "bump it in the same commit as a change to configmap.yaml". The
|
||||
image change alone would roll the pod, but the convention is followed regardless.
|
||||
|
||||
No `*.sops.yaml` file is touched. Branched from 06d4a9a, which already contains the operator's #95.
|
||||
|
||||
### Why Renovate did not do this: the schedule, not a config defect
|
||||
|
||||
- **Run 385** (dispatch, 2026-10-31 16:14 UTC, on ddd04d4) was a **real** run. It logged
|
||||
`Deleting orphan branch (… renovate/pin-dependencies)`, not a `DRY-RUN:` line. It ran at **12:14
|
||||
America/New_York**, outside `"schedule": ["after 3am and before 6am"]`, so it created and updated
|
||||
no branch.
|
||||
- **The lookup DID see the new digest.** Dependency Dashboard issue #91 lists, under **"Awaiting
|
||||
Schedule"**:
|
||||
`chore(deps): Update gitea.home.keithjackson.dev/home-network/agent-runner:main Docker digest to df36d9b`.
|
||||
- **Nothing else blocked it.** There are no open Renovate PRs and no `renovate/*` branch on Gitea
|
||||
(111 branches listed across all pages). The previous agent-runner PR, #90 (to c4eba5b), was
|
||||
auto-merged by renovate-bot at 08:22 on 10-31. No PR limit was hit, and digests are not
|
||||
dashboard-approval gated.
|
||||
- **The registry, read-only, with Docker/OCI `Accept` headers:**
|
||||
- `HEAD …/agent-runner/manifests/main` → `docker-content-digest: sha256:df36d9b7…771d`
|
||||
- `HEAD …/agent-runner/manifests/sha-ddd04d460ef6` → the same digest.
|
||||
|
||||
**Without this PR** the next scheduled run (08:15 UTC, 04:15 EDT) would open it, and it would
|
||||
auto-merge as a `digest` update. Ticking that "Awaiting Schedule" checkbox on #91 makes the next
|
||||
run of any kind create it out of schedule.
|
||||
|
||||
### Found on the way: Renovate's labels have never been applied
|
||||
|
||||
- The repo has exactly one label, `agent`. The org has none.
|
||||
- Every Renovate PR so far (#89, #90, #92, #93) was created with `"labels": []`.
|
||||
- On Gitea, Renovate drops a label that does not exist in the repo. So `needs-review`,
|
||||
`blast-radius`, `range-widen`, `own-image`, `dependencies`, `automated-update`, `breaking` and
|
||||
`ci-tooling` are never attached.
|
||||
- The **automerge flags still work.** Only the labels, the visible review signal, are missing.
|
||||
- **Operator fix:** create those labels on the repo. This was not changed here.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The new image carries the fix.** I fetched both images' layers read-only from the registry and
|
||||
read `/etc/pm-agent/worker-job-template.yaml` from each (`infra/agent/Containerfile:116` copies
|
||||
it there):
|
||||
- df36d9b's has `imagePullSecrets:` / `- name: pm-agent-registry`;
|
||||
- c4eba5b's has no `imagePullSecrets` at all.
|
||||
|
||||
This matches `infra/agent/worker-job-template.yaml:76-77` at ddd04d4.
|
||||
- **`main` = `sha-ddd04d460ef6` = df36d9b**, both measured.
|
||||
- **No other file references c4eba5b.** No admission policy pins it.
|
||||
- **No override is needed.** `infra/gitea/approval-policy.json` lists only `platform/kyverno/**`
|
||||
under `platform/`, so `platform/pm-agent/**` is not sensitive.
|
||||
- I read the whole job logs of runs 362, 367 and 385.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the rolled dispatcher starts on df36d9b and that a worker Job is admitted.** That needs
|
||||
Flux to apply this and a real dispatch. Both are operator actions.
|
||||
- **That the `pm-agent-registry` Secret exists and decrypts in-cluster** (#95's sops file). I did not
|
||||
read it.
|
||||
- **That Renovate will treat this hand bump as current.** It should: its next lookup finds
|
||||
`main` == the pinned digest, so it proposes nothing and drops the dashboard entry. Not observed.
|
||||
@@ -241,15 +241,18 @@ about runtime behaviour in this document is a design claim, not a measurement.
|
||||
`gitea.{{ k3s_base_domain }}` to the Gateway VIP in `node_baseline_hosts_pins`, the same
|
||||
pin the Keycloak issuer already carries, applied by
|
||||
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`;
|
||||
(ii) the pull is still unauthenticated and no `imagePullSecret` exists in the tree — with
|
||||
the credential decision now being local Gitea accounts with PATs, that means a
|
||||
`dockerconfigjson` for an account whose token carries `read:package`. Only then does the
|
||||
digest matter. **Superseded 2026-10-29:** the build-and-push is no longer an operator step.
|
||||
`.gitea/workflows/images.yaml` builds and verifies the image on rootless BuildKit
|
||||
(`platform/buildkit/`), publishes it on a push to `main`, and Renovate opens the one
|
||||
PR that replaces both placeholder digests (`decisions/2026-10-29-image-pipeline.md`). The
|
||||
same two blockers sit in front of `conformance-runner`, which shares the registry path and
|
||||
has never been pulled.
|
||||
(ii) the pull must be authenticated — **now wired**: `pm-agent-registry`
|
||||
(`dockerconfigjson`) is referenced by the Deployment and the committed worker Job
|
||||
template, and the admission policy pins `imagePullSecrets` to exactly that name. The
|
||||
operator supplies the value, from a Gitea token that also carries `read:package`. Only
|
||||
then does the digest matter. **Superseded 2026-10-29:** the build-and-push is no longer
|
||||
an operator step. `.gitea/workflows/images.yaml` builds and verifies the image on rootless
|
||||
BuildKit (`platform/buildkit/`) and publishes it on a push to `main`
|
||||
(`decisions/2026-10-29-image-pipeline.md`); the first published digest is pinned in
|
||||
`deployment.yaml` and `configmap.yaml`, and Renovate carries every later bump through the
|
||||
`# renovate-digest:` markers (`decisions/2026-10-30-pm-agent-live.md`). The same
|
||||
credential blocker sits in front of `conformance-runner`, which shares the registry path
|
||||
and has never been pulled.
|
||||
2. **Create the credentials** per `pm-agent-credentials.sops.yaml.example`, and **verify the
|
||||
bot's permission is `write`, not `admin`** — that measurement is the control that keeps
|
||||
the agent out of its own approval gate.
|
||||
|
||||
@@ -922,6 +922,8 @@ def selftest_template(path: str) -> None:
|
||||
assert pod.get("automountServiceAccountToken") is False, \
|
||||
"template must not mount a ServiceAccount token"
|
||||
assert pod.get("restartPolicy") == "Never", "a retried agent would double-push"
|
||||
assert [x.get("name") for x in pod.get("imagePullSecrets") or []] == ["pm-agent-registry"], \
|
||||
"the template must name exactly the committed pull secret (admission pins it)"
|
||||
assert len(pod.get("containers") or []) == 1, "exactly one container"
|
||||
c = pod["containers"][0]
|
||||
assert c.get("command") == ["/usr/local/bin/pm-worker"], "command must be the pinned entrypoint"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# digest. For a LOCAL build on a local toolbox, override it:
|
||||
# infra/devcontainer/dev.sh build && AGENT_BASE_IMAGE=home-k3s-dev:local infra/agent/build-image.sh
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
|
||||
AGENT_BASE_IMAGE=gitea.home.keithjackson.dev/home-network/toolbox@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
AGENT_BASE_IMAGE=gitea.home.keithjackson.dev/home-network/toolbox@sha256:d435fabd6fc43bf5bb4de0c1ea8c4b19d8cea5c858679bd1675ccbed471b528a
|
||||
|
||||
# Node.js — the runtime for the Claude Code CLI, installed from the Fedora base's module
|
||||
# stream. A major version, not a patch: the CLI supports the active LTS line and the
|
||||
|
||||
@@ -68,6 +68,13 @@ spec:
|
||||
# guarantee it cannot use one is for it not to have one.
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
# The kubelet's pull credential, the same one the dispatcher uses. Pinned by the
|
||||
# admission policy to exactly this name: `imagePullSecrets` is a pod-spec field, so a
|
||||
# Job could otherwise name any Secret in the namespace and have the kubelet read it
|
||||
# before a container ever starts. The image is digest-pinned, so the leverage is small
|
||||
# — but "only the committed shape" is the rule this template exists to express.
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
|
||||
@@ -111,6 +111,12 @@ spec:
|
||||
=(hostNetwork): "false"
|
||||
=(hostPID): "false"
|
||||
=(hostIPC): "false"
|
||||
# The kubelet reads this Secret BEFORE any container starts, so it is part
|
||||
# of the shape even though no container references it. A pattern on an array
|
||||
# asserts "at least one element matches", which is enough to REQUIRE the
|
||||
# committed entry; rule 7 is what forbids a second one.
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
containers:
|
||||
- image: "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:*"
|
||||
command:
|
||||
@@ -336,6 +342,54 @@ spec:
|
||||
# dispatcher itself uses envFrom (it is a Deployment, not a Job, and not matched by this
|
||||
# policy); a worker must not.
|
||||
# =====================================================================================
|
||||
# =====================================================================================
|
||||
# Rule 7 — exactly one imagePullSecret, and it is the committed one.
|
||||
#
|
||||
# `imagePullSecrets` is a pod-spec field that no container references, which is precisely
|
||||
# why it is easy to leave unread — the same shape of omission as `initContainers` and
|
||||
# `env` before it. The kubelet reads the named Secret, in this namespace, before any
|
||||
# container starts. A Job naming a different one would have the kubelet read a Secret the
|
||||
# worker was never meant to touch.
|
||||
#
|
||||
# The leverage is genuinely small: the image is digest-pinned, so a different credential
|
||||
# cannot change WHAT runs, and the only Secrets in this namespace are the agent's own.
|
||||
# It is pinned anyway, because "only the committed shape" is the whole premise of this
|
||||
# policy, and because the cost of the rule is four lines while the cost of rediscovering
|
||||
# the omission is an incident.
|
||||
#
|
||||
# Rule 1's pattern REQUIRES the committed entry; this forbids any OTHER entry and any
|
||||
# second entry. A foreach over an absent list yields no result, which is why both halves
|
||||
# are needed.
|
||||
# =====================================================================================
|
||||
- name: worker-pull-secret-must-be-the-committed-one
|
||||
match:
|
||||
all:
|
||||
- resources:
|
||||
kinds:
|
||||
- Job
|
||||
namespaces:
|
||||
- pm-agent
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: pm-agent
|
||||
namespace: pm-agent
|
||||
validate:
|
||||
message: >-
|
||||
A worker Job may declare exactly ONE imagePullSecret, named `pm-agent-registry`.
|
||||
The kubelet reads that Secret before any container starts, so naming another one
|
||||
reaches a credential the worker was never meant to touch.
|
||||
foreach:
|
||||
- list: "request.object.spec.template.spec.imagePullSecrets"
|
||||
deny:
|
||||
conditions:
|
||||
any:
|
||||
- key: "{{ length(request.object.spec.template.spec.imagePullSecrets) }}"
|
||||
operator: NotEquals
|
||||
value: 1
|
||||
- key: "{{ element.name }}"
|
||||
operator: AnyNotIn
|
||||
value: ["pm-agent-registry"]
|
||||
|
||||
- name: worker-may-not-use-envfrom
|
||||
match:
|
||||
all:
|
||||
|
||||
@@ -56,6 +56,11 @@ results:
|
||||
kind: Job
|
||||
resources: [good-run, real-template]
|
||||
result: pass
|
||||
- policy: pm-agent-job-shape
|
||||
rule: worker-pull-secret-must-be-the-committed-one
|
||||
kind: Job
|
||||
resources: [good-run, real-template]
|
||||
result: pass
|
||||
|
||||
# ---------------- THE escalation, and the shape rule's other refusals ----------------
|
||||
# `evil-command` is the case the whole model exists for: right namespace, right creator,
|
||||
@@ -73,6 +78,14 @@ results:
|
||||
- host-namespaces
|
||||
- restart-onfailure
|
||||
- second-container
|
||||
- no-pull-secret
|
||||
result: fail
|
||||
|
||||
# ---------------- the pull secret: a second one, or a different one ----------------
|
||||
- policy: pm-agent-job-shape
|
||||
rule: worker-pull-secret-must-be-the-committed-one
|
||||
kind: Job
|
||||
resources: [extra-pull-secret, wrong-pull-secret]
|
||||
result: fail
|
||||
|
||||
# ---------------- args, and the second container ----------------
|
||||
|
||||
@@ -31,6 +31,8 @@ spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
@@ -95,6 +97,8 @@ spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
@@ -492,3 +496,75 @@ spec:
|
||||
readOnlyRootFilesystem: true
|
||||
envFrom:
|
||||
- secretRef: { name: pm-agent-credentials }
|
||||
---
|
||||
# A SECOND pull secret beside the committed one. containers[0] is perfectly compliant, and
|
||||
# rule 1's array pattern is satisfied by the first element — which is why the count is
|
||||
# asserted separately.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: extra-pull-secret
|
||||
namespace: pm-agent
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
- name: pm-agent-credentials
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
command: ["/usr/local/bin/pm-worker"]
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
---
|
||||
# A DIFFERENT pull secret — the kubelet would read a Secret the worker was never meant to
|
||||
# touch, before any container starts.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: wrong-pull-secret
|
||||
namespace: pm-agent
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-credentials
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
command: ["/usr/local/bin/pm-worker"]
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
---
|
||||
# NO pull secret at all. Rule 7's foreach yields nothing over an absent list, so this is
|
||||
# rule 1's pattern to refuse — which is why the requirement lives in both.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: no-pull-secret
|
||||
namespace: pm-agent
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
command: ["/usr/local/bin/pm-worker"]
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
+40
-21
@@ -46,15 +46,17 @@ waive the sensitive-path human review on its own pull request.
|
||||
Both are deliberate, and both fail **visibly** rather than silently:
|
||||
|
||||
1. **The image is published by CI, not by a person.** `platform/pm-agent/deployment.yaml` and
|
||||
`PM_WORKER_IMAGE` in `configmap.yaml` carry an all-zero digest placeholder until the first
|
||||
publish. `.gitea/workflows/images.yaml` builds `agent-runner` on rootless BuildKit
|
||||
`PM_WORKER_IMAGE` in `configmap.yaml` carry the digest of the **first** publish (images
|
||||
run 340, `sha-523cd9118f95`), pinned once on 2026-10-30 so going live did not wait on
|
||||
Renovate's first run; every later bump is Renovate's. That digest predates the worker
|
||||
template's `imagePullSecrets` entry, so worker Jobs built from it are refused by
|
||||
admission — which is why `PM_DRY_RUN` starts `true` (see `configmap.yaml`). `.gitea/workflows/images.yaml` builds `agent-runner` on rootless BuildKit
|
||||
(`platform/buildkit/`) — the same Containerfile, and the same `verify` stage, that
|
||||
`infra/agent/build-image.sh` builds locally — and on a push to `main` publishes it to
|
||||
`gitea.home.keithjackson.dev/home-network/agent-runner` under `sha-<commit>` and `main`.
|
||||
Renovate then follows the `main` tag through the `# renovate-digest:` marker above **both**
|
||||
lines and moves them together in one pull request. Nobody builds-and-pushes this image from
|
||||
a workstation, and nobody pastes a digest. Until the first publish, and until that PR
|
||||
merges: `ImagePullBackOff`. The one operator prerequisite on the publishing side is the
|
||||
a workstation. The one operator prerequisite on the publishing side is the
|
||||
`REGISTRY_PUSH_TOKEN` Actions secret (see the header of `.gitea/workflows/images.yaml`);
|
||||
without it the publish job fails loudly rather than skipping.
|
||||
|
||||
@@ -78,21 +80,36 @@ Both are deliberate, and both fail **visibly** rather than silently:
|
||||
inventory, never typed on a node. Apply it scoped to that one task:
|
||||
`cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod
|
||||
--tags hosts`.
|
||||
- **The pull is still unauthenticated** once the node reaches the right endpoint. An
|
||||
unauthenticated client gets nothing from this Gitea's registry, and there is no
|
||||
`imagePullSecret` anywhere in the tree. Consistent with the agent credential decision
|
||||
(local Gitea accounts with PATs), the fix is a `dockerconfigjson` for a local Gitea
|
||||
account whose token carries `read:package`, referenced from the Deployment and the
|
||||
committed worker Job template — not built yet, because minting that token is yours.
|
||||
- **The pull must be authenticated** once the node reaches the right endpoint: an
|
||||
unauthenticated client gets nothing from this registry. **Now wired** —
|
||||
`pm-agent-registry` is a `dockerconfigjson` Secret referenced by both the Deployment and
|
||||
the committed worker Job template, and the admission policy pins it to exactly that
|
||||
name. What is left to you is the value: the Gitea token must carry **`read:package`** in
|
||||
addition to `read:user`, `write:repository` and `write:issue`, and the dockerconfigjson is built from
|
||||
it. Steps:
|
||||
[`pm-agent-registry.sops.yaml.example`](./pm-agent-registry.sops.yaml.example).
|
||||
|
||||
The same two apply to `conformance-runner`: same registry path, same publish-then-Renovate
|
||||
model, never pulled either.
|
||||
2. **The credentials are placeholders.** `pm-agent-credentials.sops.yaml` is encrypted to the
|
||||
repository's age recipient but holds `REPLACE-ME-…` strings. The hand-off —
|
||||
which Gitea account to create, which scopes, and why it must *not* be an administrator —
|
||||
is in [`pm-agent-credentials.sops.yaml.example`](./pm-agent-credentials.sops.yaml.example).
|
||||
Until then the dispatcher CrashLoops with `GET /user returned no login — is GITEA_TOKEN
|
||||
valid?`.
|
||||
2. **The credentials are placeholders — two Secrets, one token.** Both are encrypted to the
|
||||
repository's age recipient and both hold `REPLACE-ME-…` strings:
|
||||
|
||||
| Secret | type | read by | hand-off |
|
||||
|---|---|---|---|
|
||||
| `pm-agent-credentials` | Opaque | the application, as env | [`.example`](./pm-agent-credentials.sops.yaml.example) |
|
||||
| `pm-agent-registry` | dockerconfigjson | the **kubelet**, before any container starts | [`.example`](./pm-agent-registry.sops.yaml.example) |
|
||||
|
||||
They hold the **same** Gitea token in two formats, because a Secret cannot be both — the
|
||||
same one-value-in-two-files pattern the Keycloak client secret already uses here. So mint
|
||||
it once, with all four scopes: `read:package` (the pull), `read:user` (the dispatcher's
|
||||
startup `GET /user` — Gitea 1.24.3 scopes the whole `/user` group), `write:repository`
|
||||
(clone, branch push, pull request) and `write:issue` (issue comments). The account also
|
||||
needs package *permission*, not just the scope: a `pm-agent-pull` org team with Packages
|
||||
= Read and no repositories (steps in the registry `.example`). The first `.example`
|
||||
explains which account to create and why it must *not* be an administrator.
|
||||
|
||||
Until they are filled in: the pull fails `401`, and if you fix only the pull, the
|
||||
dispatcher CrashLoops with `GET /user returned no login — is GITEA_TOKEN valid?`.
|
||||
|
||||
Its Flux Kustomization is `wait: false` precisely because of those two states: a workload
|
||||
that is *expected* to be unhealthy for a while must never be able to gate anything.
|
||||
@@ -155,13 +172,14 @@ Job from a committed template. **The model's output is data, never a command.**
|
||||
| **cannot** | clone from or push to anywhere but `gitea.gitea.svc.cluster.local:3000`: `pm-worker` validates `PM_GITEA_URL` against a one-entry allowlist baked into the image. In-cluster consumers reach a service by its cluster-local name — a different path by design from how a client outside the cluster reaches it — so the external hostname is not an accepted value |
|
||||
| **cannot** | authenticate a `git push` **during the model session**: `GIT_ASKPASS` is unset for its duration, so the restriction is structural rather than a request in a prompt |
|
||||
| **cannot** | be created in any shape but the committed one — see the admission policy |
|
||||
| **cannot** | have the kubelet read any Secret but `pm-agent-registry` on its behalf: `imagePullSecrets` is pinned to exactly that one name |
|
||||
|
||||
### What is *not* defended, stated plainly
|
||||
|
||||
- **The worker's own credentials are inside the model's reach.** The session runs as the same
|
||||
uid in the same container as the wrapper, so it can read the environment, including its
|
||||
`GITEA_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`. There is no isolation between them and none is
|
||||
claimed. What bounds it: the Gitea token is scoped `write:repository, write:issue` on **one
|
||||
claimed. What bounds it: the Gitea token is scoped `read:user, write:repository, write:issue, read:package` on **one
|
||||
repository** with no admin permission, and egress reaches only this Gitea and tcp/443.
|
||||
- **`world:443` is wider than `api.anthropic.com`.** Cilium can express the tighter rule
|
||||
(`toFQDNs`) but nothing in this repository uses FQDN policy and its prerequisite (Cilium's
|
||||
@@ -200,14 +218,15 @@ Two things answer it, and neither is sufficient alone:
|
||||
`command` with no args, that powerless SA, no mounted token, `restartPolicy: Never`,
|
||||
non-root with no privilege escalation and a read-only root filesystem, only the
|
||||
`workspace`/`tmp` **emptyDir** volumes, no init or ephemeral containers, only the
|
||||
committed env **names**, and no `envFrom`.
|
||||
committed env **names**, no `envFrom`, and exactly one `imagePullSecret` — the committed
|
||||
one.
|
||||
|
||||
Six rules, because pinning `command` only moves the injection to whatever adjacent field
|
||||
Seven rules, because pinning `command` only moves the injection to whatever adjacent field
|
||||
nobody enumerated — on the sibling policy that was `initContainers` (measured: a Job with a
|
||||
compliant `containers[0]` and a privileged init container was **admitted**, pass 3 fail 0),
|
||||
then `ephemeralContainers`, then `env`, then `envFrom`. All of them are closed here from the
|
||||
start, and `platform/kyverno/tests/pm-agent/` proves each refusal offline: **28 assertions,
|
||||
28 passing** under the pinned `kyverno` CLI. The worker's own input guards have their own
|
||||
start, and `platform/kyverno/tests/pm-agent/` proves each refusal offline: **33 assertions,
|
||||
33 passing** under the pinned `kyverno` CLI. The worker's own input guards have their own
|
||||
offline probe — `infra/agent/probe-validation.sh`, **17 refusals** — because a guard nothing
|
||||
exercises is a claim rather than a control.
|
||||
|
||||
|
||||
@@ -51,15 +51,20 @@ data:
|
||||
# the dispatcher and its workers share the committed Job template and the env-name
|
||||
# contract. A tag here is refused at startup (and would be DENIED by admission anyway).
|
||||
#
|
||||
# PLACEHOLDER UNTIL THE IMAGE IS PUBLISHED. The all-zero digest below is deliberately
|
||||
# impossible. CI builds, verifies and — on main — publishes agent-runner
|
||||
# (.gitea/workflows/images.yaml), and Renovate replaces this value with the digest the
|
||||
# REGISTRY returned for the image's `main` tag, in a pull request that also moves the
|
||||
# Deployment's image (same marker, same branch). Nobody pushes this image from a workstation.
|
||||
# Until the first publish the dispatcher starts, fails to pull, and says so — which is the
|
||||
# visible failure, not a silent one.
|
||||
# THE FIRST PUBLISHED DIGEST. CI builds, verifies and — on main — publishes agent-runner
|
||||
# (.gitea/workflows/images.yaml). This value is images run 340's publish (tag
|
||||
# sha-523cd9118f95, which `main` pointed at), read from the registry on 2026-10-30. From here
|
||||
# on Renovate replaces it with the digest the REGISTRY returns for the `main` tag, in a pull
|
||||
# request that also moves the Deployment's image (same marker, same branch). Nobody pushes
|
||||
# this image from a workstation.
|
||||
#
|
||||
# READ BEFORE TURNING PM_DRY_RUN OFF: this digest PREDATES the worker template's
|
||||
# `imagePullSecrets` entry (PR #61). Its baked template has none, and admission now REQUIRES
|
||||
# it — so a worker Job built from this image is DENIED. The merge that adds the entry
|
||||
# changes infra/agent/**, CI re-publishes agent-runner, and Renovate's bump to that digest
|
||||
# is what makes worker Jobs admissible. Until then, dry run is the only mode that works.
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/agent-runner tag=main
|
||||
PM_WORKER_IMAGE: "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
PM_WORKER_IMAGE: "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:df36d9b7fef56d141220a387f64fb864cede8e5af30513ebfeb3ab566d3f771d"
|
||||
|
||||
# --- the model -------------------------------------------------------------
|
||||
# The PM only reads a thread and answers with one JSON object, so it holds no tools and
|
||||
@@ -104,13 +109,21 @@ data:
|
||||
# work somebody deliberately handed it while trust is being established, with no code
|
||||
# change and no redeploy of anything but this ConfigMap. The operator's call; the default
|
||||
# here honours the request as made.
|
||||
PM_REQUIRE_LABEL: ""
|
||||
#
|
||||
# SET TO `agent` FOR THE FIRST RUN (2026-10-30, docs/plans/pm-agent-issue-to-pr.md
|
||||
# follow-up 3). The label does not exist in the repository yet: create it in Gitea
|
||||
# (Issues -> Labels -> New Label, name `agent`) before labelling a test issue.
|
||||
PM_REQUIRE_LABEL: "agent"
|
||||
|
||||
# Dry run: the PM still reads threads, still asks the model and still posts its decision as
|
||||
# an issue comment — so the operator can read exactly what it WOULD have done — but creates
|
||||
# no worker Job and so changes no code. The honest way to watch this service for a day
|
||||
# before letting it write anything.
|
||||
PM_DRY_RUN: "false"
|
||||
#
|
||||
# "true" FOR THE FIRST RUN, and not only for caution: the pinned digest above cannot yet
|
||||
# create an admissible worker Job (see the PM_WORKER_IMAGE comment). Flip to "false" in the
|
||||
# same pull request as — or after — Renovate's first agent-runner digest bump.
|
||||
PM_DRY_RUN: "true"
|
||||
|
||||
# If the gate refuses a worker's change, the branch is NOT pushed by default and the issue
|
||||
# gets the gate log instead. Set to "true" to push anyway and open a red pull request —
|
||||
|
||||
@@ -48,9 +48,13 @@ spec:
|
||||
# It is a hand-maintained marker rather than a computed hash because Flux's
|
||||
# Kustomization here does not run a configmap generator; bump it in the same commit
|
||||
# as a change to configmap.yaml.
|
||||
pm-agent.homelab.keithjackson.dev/config-generation: "1"
|
||||
pm-agent.homelab.keithjackson.dev/config-generation: "3"
|
||||
spec:
|
||||
serviceAccountName: pm-agent
|
||||
# The kubelet's credential for pulling this image. Not the agent's Gitea credential —
|
||||
# see pm-agent-registry.sops.yaml.example for why they are two Secrets of one token.
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
# The dispatcher DOES need its token: `kubectl create job` is its one privileged act,
|
||||
# and platform/pm-agent/rbac.yaml is what bounds it to that. Contrast the worker, whose
|
||||
# Job template sets this false because it needs no API access at all.
|
||||
@@ -69,10 +73,12 @@ spec:
|
||||
# disagree about the shape the admission policy enforces. Replaced together, in ONE
|
||||
# Renovate pull request: both lines carry the same marker, so Renovate treats them as
|
||||
# one dependency and bumps them in the same branch. The digest comes from CI's publish
|
||||
# of agent-runner's `main` tag (.gitea/workflows/images.yaml) — the all-zero
|
||||
# placeholder below is impossible on purpose (see configmap.yaml PM_WORKER_IMAGE).
|
||||
# of agent-runner's `main` tag (.gitea/workflows/images.yaml). The value below is the
|
||||
# FIRST publish (images run 340, tag sha-523cd9118f95 = main at the time), read from
|
||||
# the registry's manifest endpoint on 2026-10-30 and pinned by hand once so the
|
||||
# dispatcher does not wait on Renovate's first run; every later bump is Renovate's.
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/agent-runner tag=main
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:df36d9b7fef56d141220a387f64fb864cede8e5af30513ebfeb3ab566d3f771d
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /usr/local/bin/pm-dispatch
|
||||
|
||||
@@ -35,6 +35,12 @@ resources:
|
||||
# the distance between naming a file in a comment and listing it under `resources:` is one
|
||||
# careless edit.
|
||||
- pm-agent-credentials.sops.yaml
|
||||
# The KUBELET's pull credential for agent-runner — a separate Secret from the one above
|
||||
# because it is a different consumer in a different format: dockerconfigjson, read before
|
||||
# any container starts. Same underlying Gitea token, which must also carry `read:package`.
|
||||
# Measured 2026-10-05: this registry refuses unauthenticated clients outright, so without
|
||||
# this the pull 401s. Also placeholders until the operator fills it in.
|
||||
- pm-agent-registry.sops.yaml
|
||||
- deployment.yaml
|
||||
# default-deny baseline + the two egress allow policies (gitea, kube-apiserver for the
|
||||
# dispatcher only, tcp/443 for the model). NO ingress allow exists, by design.
|
||||
|
||||
@@ -11,8 +11,8 @@ metadata:
|
||||
app.kubernetes.io/part-of: pm-agent
|
||||
type: Opaque
|
||||
stringData:
|
||||
gitea-token: ENC[AES256_GCM,data:Vs6TkiNocU+qzvlv8mndXhtiqp1RyY00KhIBDuL8w0WoX72B8DxfPK8eWISy9gtx9TBlYpu2FHP7,iv:2z8+gzWuZDFACb06rnYg071h4wO7YlcTFNZDQzDxRNs=,tag:cUhj+uQUwu44HpPtFTVjVA==,type:str]
|
||||
claude-code-oauth-token: ENC[AES256_GCM,data:2PjA5MOx8Enm4wxqYdJ3QY8juqPkXh4adQv3IRtw1Nb2Dw6SkOFc,iv:ZSlefJZqCN7g17D+27VkkVYu8F1umM7JOG+f/crZtsE=,tag:QyNYIbHr+ANS0Rx4USa0Iw==,type:str]
|
||||
gitea-token: ENC[AES256_GCM,data:nkQT4m0DYykNcEUPV+1A4APuraekHN+8tY74HnRIbwU8SwYbRRqfKQ==,iv:d6Lj4Q/ys381msrrRtIIYbV9Ey3Nhua4zmX/7aCMlSk=,tag:xKKmc1i9TRuUVc/C2P1zHg==,type:str]
|
||||
claude-code-oauth-token: ENC[AES256_GCM,data:Z5elUXr2wiu1S3FvSsHPNfhyrh+dGAdBf4HEmvg+Ky3QuCDpLv/XcMG5wWNywYVR4jlx/z2rm2FjjRYo+8rG0+I3SzriKG2MD7Zri4bDUf+DFksa6pI2oGDmgTjp8Zjz8oGapaMurRnuYcMm,iv:tXADrr2PD1zTpmTuaCA224wzbsq2RKzx0/ylU2k6suE=,tag:DMh31sAj0E/8ndcP1M6zAw==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
@@ -28,8 +28,8 @@ sops:
|
||||
V3d1UGVsTlljS3lHbFRkUWE4aVdRSDAK3vlChFXHg+jOJyjzUBZ4F4NqIn8rr5xl
|
||||
WAH9bgY14yvX8qm3P4iLeFecNssqUvs2hvDW82vhMfUQlqYpTTXyjQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-10-06T03:09:21Z"
|
||||
mac: ENC[AES256_GCM,data:lKj3CtZ3fCvOwuJSeNb5x8dj+5O0Uhfyoir+ZfIGD52xgeTWtmQ6CmOVbtrYjp1h1dgZnWopbibfMIKE9AmjSAHA7UJOen++zcy4qrA8Yh8xGzfhb1tOJplxZP+Lp9319VYQHqXqOScs7at8Osnw2RYuibW/j6eIY0B17JzIXvo=,iv:14aKtsMd9Q6QqnWVszoi+W2+z4QPfXUd7oZfK46+gRM=,tag:NwBg43/vNeFlgHmNLB7rYw==,type:str]
|
||||
lastmodified: "2026-10-31T16:07:12Z"
|
||||
mac: ENC[AES256_GCM,data:rK1GR6FoIsh2Y6VSQSkLhGwf1U2kBrV3VYZT/5X+TxbkURza7DOTUyMJsY0oLKApvkTvOxLd3/WfzBNW/O6Tho1S4otvi4UC7fY6DjC46LmrfSlAxoRp7ov/oIRfwLBUanpNzfT94fu7sjoNM0lRlFp9iSpBU44upcIWwclK5WY=,iv:EujucxcPU+zfN9KmOhbqfVT5anv4g/jRg41hu5BgGx8=,tag:fViHjTT6w20b2AsXZodq0g==,type:str]
|
||||
pgp: []
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
version: 3.9.4
|
||||
@@ -60,11 +60,21 @@
|
||||
# Keycloak service-account token therefore cannot authenticate to Gitea's API or to
|
||||
# git-over-HTTP. Do not "upgrade" this to Keycloak without re-reading that.
|
||||
#
|
||||
# Scopes: `write:repository` and `write:issue`. Nothing else — in particular NOT
|
||||
# `write:admin`, NOT `write:organization`, NOT `write:user`.
|
||||
# Scopes, exactly these four (one token serves both Secrets — see
|
||||
# pm-agent-registry.sops.yaml.example):
|
||||
# read:user GET /user, the dispatcher's startup identity check. Gitea 1.24.3
|
||||
# puts the whole /api/v1/user group behind the `user` scope
|
||||
# category (routers/api/v1/api.go), so without it the dispatcher
|
||||
# CrashLoops on its first call even with an otherwise-valid token.
|
||||
# write:repository the clone, the branch push, the pull-request create, and the
|
||||
# dispatcher's reads of pulls/branches/commits.
|
||||
# write:issue reading open issues and posting issue comments.
|
||||
# read:package the kubelet's image pull (pm-agent-registry).
|
||||
# Nothing else — in particular NOT `write:admin`, NOT `write:organization`, NOT
|
||||
# `write:user`. (Until 2026-10-30 this list said two scopes; `read:user` was missing
|
||||
# and `read:package` lived only in the registry template.)
|
||||
#
|
||||
# `write:repository` covers the clone, the branch push and the pull-request create;
|
||||
# `write:issue` covers reading and posting issue comments. (The repository's OTHER CI
|
||||
# (The repository's OTHER CI
|
||||
# token, `SETTINGS_API_TOKEN`, is a separate credential with `read:admin` — do not
|
||||
# conflate them, and do not give this service that one. The 2026-10-02 measurement on
|
||||
# PR #38 recorded in infra/gitea/approval-policy.json is about that token, not this one.)
|
||||
@@ -94,6 +104,17 @@
|
||||
#
|
||||
# (4) COPY, FILL, ENCRYPT, COMMIT:
|
||||
#
|
||||
# `sops` IS IN THE PINNED TOOLBOX, NOT ON THE HOST (measured 2026-10-30: absent from the
|
||||
# workstation's PATH; sops 3.9.4 and vi in the toolbox). The simplest path needs no
|
||||
# plaintext file at all — edit the committed, already-encrypted file in place, from a
|
||||
# worktree, the way runbooks/vpn-remote-access.md does for its key:
|
||||
#
|
||||
# ./infra/devcontainer/dev.sh run sops platform/pm-agent/pm-agent-credentials.sops.yaml
|
||||
#
|
||||
# It opens vi on the decrypted document; replace the REPLACE-ME value(s), save, quit, and
|
||||
# sops re-encrypts on write. If you take the cp route below instead, run the `sops -e -i`
|
||||
# line through `./infra/devcontainer/dev.sh run` as well, and delete any plaintext temp file.
|
||||
#
|
||||
# cp platform/pm-agent/pm-agent-credentials.sops.yaml.example \
|
||||
# platform/pm-agent/pm-agent-credentials.sops.yaml
|
||||
# $EDITOR platform/pm-agent/pm-agent-credentials.sops.yaml # the two real values
|
||||
@@ -120,7 +141,7 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Gitea token for the dedicated, NON-ADMIN `pm-agent` account.
|
||||
# Scopes: write:repository, write:issue.
|
||||
# Scopes: read:user, write:repository, write:issue, read:package.
|
||||
gitea-token: REPLACE-ME-gitea-token
|
||||
# Output of `claude setup-token` on a subscription account. One year's validity.
|
||||
claude-code-oauth-token: REPLACE-ME-claude-code-oauth-token
|
||||
@@ -0,0 +1,39 @@
|
||||
# PLACEHOLDER VALUES — the kubelet cannot pull agent-runner until the operator replaces them.
|
||||
# Full hand-off steps: pm-agent-registry.sops.yaml.example (same directory).
|
||||
# SOPS-encrypted to the repository's age recipient; keys stay cleartext for reviewable diffs.
|
||||
#
|
||||
# This is the KUBELET's pull credential, not the agent's Gitea credential. The agent's is
|
||||
# pm-agent-credentials (Opaque, read as env by the application); this one is
|
||||
# dockerconfigjson and is read before any container starts. Same underlying Gitea token,
|
||||
# which must carry read:package as well as write:repository and write:issue.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: pm-agent-registry
|
||||
namespace: pm-agent
|
||||
labels:
|
||||
app.kubernetes.io/name: pm-agent
|
||||
app.kubernetes.io/part-of: pm-agent
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
stringData:
|
||||
.dockerconfigjson: ENC[AES256_GCM,data:NuKobTM0S0TK8U961uNTIIYiyAI2L3QCopZXvKxXHLC2tjpdyV+REKzYPmv+orCMiRsY8GNtfaHb8Hret4sQWHySAAKN0sEtL+OkzLyRH+u7irAGFYUBf8q+vIb+hnhdwejHfL1UEaHovJlZuIjqOdSItqlxqXURGtvUtBlkD7cnzDajzLyW+QTp5Gs3Fc8ps4XxRkv/589bKpn0DIEQiXUujydBZzKVnO5unxMea+4IBwcC6yaeQ+fXCoILrKD3PP2Igz0=,iv:vrQqH4dIUwDLSZGe1JKnsajtvUb7lJe3st/9K+4/w0M=,tag:P9VLwOqnTfM1uQnbousYqQ==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvdG1lQm16V0tJdWZXRXlU
|
||||
Z2R4RXVyTXd6VTVJUWtkbUVjZldnb1JFOWhVCjVMMDdDb3AwVkt3TzJzdjR0VnNs
|
||||
SFlncWg2cWlFTy9IZ1YxSC9Zd3ZMaEkKLS0tIEZFQWhIMW5zMURWYmlXaFg1ZjhD
|
||||
cG1Fb0V3VncrTm9SQ091a0J0Z3RURWMKpdzNVub0zH5S8nnCSCt2H6NcCqDbV24L
|
||||
Mf8K2eIfu/ixn2NfwoVVj2QO+n+3/P6PLphdoiKMrL+ms0+JqWFpkQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-10-31T16:11:11Z"
|
||||
mac: ENC[AES256_GCM,data:0GIYwtVcNngq2Zpz3haCx6L+j2SrgHFL5pRYeRM4Ac1T9kjKSwHik6Q4+T8FU+EJa9GP1yXrpdsjggsjzG9dI2zRyAF1C8x1znD5ls6OwT5jLak8QrHNcTavRlm6N6y1yOsQCTFMc+zeCywTnFJldTqPKH+6K/bNMNBuD14BNv8=,iv:oqGygkOmFgRw0TYbWO75/sJ/doXtorAWAg89LtaqIsg=,tag:vBO/DWV93ekZB86G+Hca2A==,type:str]
|
||||
pgp: []
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
version: 3.9.4
|
||||
@@ -0,0 +1,118 @@
|
||||
# =============================================================================
|
||||
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (`.yaml.example` is ignored by kustomize and is
|
||||
# never auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
|
||||
#
|
||||
# WHAT THIS IS: the credential the KUBELET uses to pull `agent-runner` from the in-cluster
|
||||
# Gitea registry. It is NOT the credential the agent uses to talk to Gitea — that one is
|
||||
# `pm-agent-credentials` and it is read by the application. Two Secrets, because they are
|
||||
# consumed by two different things in two different formats:
|
||||
#
|
||||
# pm-agent-credentials Opaque read by pm-dispatch / pm-worker as env vars
|
||||
# pm-agent-registry dockerconfigjson read by the KUBELET, before any container runs
|
||||
#
|
||||
# WHY A PULL CREDENTIAL IS NEEDED AT ALL. Measured 2026-10-05: an unauthenticated client
|
||||
# gets nothing from this Gitea's registry — it issues an anonymous token whose `access`
|
||||
# claim is `null`, and every path answers 401 whether it exists or not
|
||||
# (`/api/v1/packages/Home-Network` replies `{"message":"token is required"}`). So a pull
|
||||
# with no credential cannot succeed, and before this Secret there was no `imagePullSecret`
|
||||
# anywhere in the tree. See decisions/2026-10-05-pm-agent-registry-pull-prereq.md.
|
||||
#
|
||||
# THE OTHER HALF, ALREADY FIXED: the nodes used to resolve this registry's hostname to the
|
||||
# non-hairpinned WAN address and fail TLS against the router's `CN=%D` placeholder
|
||||
# certificate. That is the `/etc/hosts` pin in
|
||||
# infra/ansible/inventory/group_vars/k3s_prod/vars.yml
|
||||
# (decisions/2026-10-06-pm-agent-node-resolves-gitea-to-wan.md). Both halves are needed; a
|
||||
# pull fails on whichever is missing.
|
||||
#
|
||||
# =============================================================================
|
||||
# HAND-OFF STEPS (operator)
|
||||
# =============================================================================
|
||||
#
|
||||
# (1) USE THE SAME `pm-agent` ACCOUNT the agent already has (created per
|
||||
# pm-agent-credentials.sops.yaml.example — the dedicated, NON-ADMIN one). A second
|
||||
# account would be a second thing to keep non-admin for no gain: this token reads one
|
||||
# package and nothing else.
|
||||
#
|
||||
# (2) ADD THE PACKAGE SCOPE. Gitea token scopes are fixed at creation, so this means
|
||||
# generating a token with all four scopes and using it in BOTH Secrets:
|
||||
#
|
||||
# read:package <- NEW, what the kubelet needs to pull
|
||||
# read:user <- the dispatcher's startup GET /user (Gitea 1.24.3 scopes it)
|
||||
# write:repository <- the agent's clone / branch push / pull-request create
|
||||
# write:issue <- the agent's issue comments
|
||||
#
|
||||
# THE SCOPE IS NOT THE PERMISSION. `read:package` only lets the token use whatever
|
||||
# package access the ACCOUNT has, and these packages belong to the ORGANISATION
|
||||
# (`home-network/…`), not to a repository. Gitea 1.24.3 decides org-package access in
|
||||
# services/context/package.go `determineAccessMode`: the highest Packages-unit mode of
|
||||
# any org team the user is in, else READ if the org is visible to that user. Home-Network
|
||||
# is `public` today (measured 2026-10-30), so a signed-in non-member already gets read —
|
||||
# but that is a side effect of a visibility setting nobody chose for this. Make it
|
||||
# explicit, and independent of visibility:
|
||||
#
|
||||
# Organisation Home-Network -> Teams -> New Team
|
||||
# name: pm-agent-pull permission: none (unit-level)
|
||||
# repositories: NONE (do not tick "all repositories")
|
||||
# units: Packages = Read; every other unit = No Access
|
||||
# then add `pm-agent` as its only member.
|
||||
#
|
||||
# That grants package read on the org and nothing on any repository; the repo `Write`
|
||||
# comes only from the collaborator entry in the credentials template.
|
||||
#
|
||||
# Gitea -> (as pm-agent) Settings -> Applications -> Generate New Token.
|
||||
# Put the SAME value in `pm-agent-credentials` (`gitea-token`) and in the
|
||||
# `.dockerconfigjson` below. One token, two consumers, two formats — the same
|
||||
# one-value-in-two-files pattern the Keycloak client secret already uses here, and for
|
||||
# the same reason: a Secret cannot be shared across formats.
|
||||
#
|
||||
# (3) BUILD THE DOCKERCONFIGJSON. `auth` is base64 of `<username>:<token>` — NOT a hash,
|
||||
# just encoding, so treat this file exactly as secret as the token itself:
|
||||
#
|
||||
# USER=pm-agent
|
||||
# TOKEN='<the token from step 2>'
|
||||
# AUTH=$(printf '%s:%s' "$USER" "$TOKEN" | base64 -w0)
|
||||
# cat > /tmp/dockerconfig.json <<JSON
|
||||
# {"auths":{"gitea.home.keithjackson.dev":{"username":"$USER","password":"$TOKEN","auth":"$AUTH"}}}
|
||||
# JSON
|
||||
#
|
||||
# THE REGISTRY KEY MUST BE THE HOST AS THE IMAGE REFERENCE SPELLS IT —
|
||||
# `gitea.home.keithjackson.dev`, no scheme, no port, no path. The kubelet matches the
|
||||
# pull host against these keys literally; `https://…` or a trailing `/v2/` silently
|
||||
# matches nothing and the pull is attempted anonymously, which 401s exactly as if this
|
||||
# Secret were absent.
|
||||
#
|
||||
# (4) COPY, FILL, ENCRYPT, COMMIT:
|
||||
#
|
||||
# `sops` IS IN THE PINNED TOOLBOX, NOT ON THE HOST (measured 2026-10-30: absent from the
|
||||
# workstation's PATH; sops 3.9.4 and vi in the toolbox). The simplest path needs no
|
||||
# plaintext file at all — edit the committed, already-encrypted file in place, from a
|
||||
# worktree, the way runbooks/vpn-remote-access.md does for its key:
|
||||
#
|
||||
# ./infra/devcontainer/dev.sh run sops platform/pm-agent/pm-agent-registry.sops.yaml
|
||||
#
|
||||
# It opens vi on the decrypted document; replace the REPLACE-ME value(s), save, quit, and
|
||||
# sops re-encrypts on write. If you take the cp route below instead, run the `sops -e -i`
|
||||
# line through `./infra/devcontainer/dev.sh run` as well, and delete any plaintext temp file.
|
||||
#
|
||||
# cp platform/pm-agent/pm-agent-registry.sops.yaml.example \
|
||||
# platform/pm-agent/pm-agent-registry.sops.yaml
|
||||
# # paste the one-line JSON from step 3 as the .dockerconfigjson value
|
||||
# sops -e -i platform/pm-agent/pm-agent-registry.sops.yaml
|
||||
#
|
||||
# The root `.sops.yaml` rule matches this path and encrypts `stringData`, so the diff
|
||||
# stays reviewable. NEVER `sops -d` it into a terminal or a transcript (CLAUDE.md §6).
|
||||
# It is already listed under `resources:` in platform/pm-agent/kustomization.yaml.
|
||||
#
|
||||
# (5) ROTATION. Rotating the Gitea token means updating BOTH Secrets, because it is one
|
||||
# token. The dispatcher must then be restarted to re-read its env; worker Jobs and the
|
||||
# kubelet pick the new value up on their own.
|
||||
# =============================================================================
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: pm-agent-registry
|
||||
namespace: pm-agent
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
stringData:
|
||||
# One line. Registry key = the host exactly as the image reference spells it.
|
||||
.dockerconfigjson: '{"auths":{"gitea.home.keithjackson.dev":{"username":"pm-agent","password":"REPLACE-ME-the-same-gitea-token","auth":"REPLACE-ME-base64-of-username:token"}}}'
|
||||
@@ -325,6 +325,37 @@
|
||||
{ "matchPackageNames": ["lscr.io/linuxserver/radarr"], "allowedVersions": "<6.0.0" },
|
||||
{ "matchPackageNames": ["lscr.io/linuxserver/sonarr"], "allowedVersions": "<5.0.0" },
|
||||
{ "matchPackageNames": ["semaphoreui/semaphore"], "allowedVersions": "<3.0.0" },
|
||||
// ADDED 2026-10-30 — pihole, jackett and plex, PINNED to the digests that were already running
|
||||
// (catalog/{pihole,jackett,plex}.yaml; each tag was resolved against the registry and matched the
|
||||
// pods' imageID, so the pin changed nothing at runtime). None of the three catalog `update.range`
|
||||
// values has a ceiling (pihole >=2024.0.0, jackett >=0.22.0, plex >=1.40.0), so the
|
||||
// allowedVersions mirror above needs no entry for them.
|
||||
//
|
||||
// pihole/pihole needs NO rule: its CalVer tags (YYYY.MM.N, e.g. 2026.10.0) parse under the
|
||||
// default `docker` versioning. Patches and minors (2026.10.0 -> 2026.10.1 / 2026.11.0) therefore
|
||||
// AUTO-MERGE on a green gate under rule 1 — the operator's explicit choice, not an oversight; do
|
||||
// not add a hold here. A YEAR ROLLOVER (2026.x -> 2027.x) is a MAJOR, so it waits for a tick on
|
||||
// the Dependency Dashboard (rule below) and is never auto-merged.
|
||||
{
|
||||
"description": "ADDED 2026-10-30. LinuxServer tags: `vX.Y.Z-lsN` is the IMMUTABLE build tag (bare `X.Y.Z` and `latest` are re-pushed for every ls rebuild). Docker versioning would treat `-ls7` as a compatibility suffix and only ever compare tags with the SAME suffix, so it would never move. This is Renovate's own regex-versioning example for linuxserver images: the ls build number becomes the `build` component, so an ls rebuild of the same Jackett is an update too. The `^v` and `$` anchors exclude `latest`, `development`, the bare mutable `X.Y.Z` tags and the arch-prefixed (`amd64-v...`) tags.",
|
||||
"matchDepNames": ["lscr.io/linuxserver/jackett"],
|
||||
"versioning": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-ls(?<build>\\d+)$"
|
||||
},
|
||||
{
|
||||
"description": "ADDED 2026-10-30. LOOK JACKETT UP ON ghcr.io, NOT lscr.io. lscr.io is LinuxServer's redirector in front of ghcr.io/linuxserver (same manifests: v0.24.2865-ls7 is sha256:4fcf3cc2... on both). Renovate's docker datasource fetches up to 1000 pages of tags from ghcr.io but only `dockerMaxPages` (default 20, a GLOBAL-only option) from any other host (lib/modules/datasource/docker/index.js getTags). Jackett has 32,093 tags at ~1000 per page (measured 2026-10-30), oldest first; the running tag is #31,980, so a lookup via lscr.io never sees it or anything newer and silently proposes NOTHING (measured in a dry run before this rule). overridePackageName changes only the lookup; the file keeps `lscr.io/...`, so the deployed image string does not change. Matched by depName because packageName is what this rule rewrites.",
|
||||
"matchDepNames": ["lscr.io/linuxserver/jackett"],
|
||||
"overridePackageName": "ghcr.io/linuxserver/jackett"
|
||||
},
|
||||
{
|
||||
"description": "ADDED 2026-10-30. Jackett publishes ~daily (13 ls builds in 12 days, measured 2026-10-30) and every one is a `patch` under the regex above, i.e. auto-mergeable. Left on the global daily window that is a PR, a gate run and a jackett pod restart EVERY DAY for an indexer proxy whose individual builds nobody needs promptly. WEEKLY instead: Renovate proposes only the newest tag when the window opens, so nothing is skipped, it is just batched. The window is still inside the global 03:00-06:00 local one that .gitea/workflows/renovate.yaml's 08:15 UTC cron lands in. NOTE: catalog/jackett.yaml `update.cadence` still says daily (`0 4 * * *`); that field drove Flux image-automation, which is retired, and this rule is the effective cadence now.",
|
||||
"matchDepNames": ["lscr.io/linuxserver/jackett"],
|
||||
"schedule": ["after 3am and before 6am on sunday"]
|
||||
},
|
||||
{
|
||||
"description": "ADDED 2026-10-30. Plex tags are `<major>.<minor>.<patch>.<build>-<commit hex>` (e.g. 1.43.4.10903-e5521bd8c). Docker versioning reads `-e5521bd8c` as a compatibility suffix that differs on EVERY release, so it would never find a successor. The trailing `$` excludes the per-arch variants and the floating `latest`/`public`/`beta`/`plexpass` tags. A version tag does NOT self-update inside the container: cont-init 50-plex-update exits early when /version.txt matches the installed package, so what is pinned is what runs.",
|
||||
"matchPackageNames": ["plexinc/pms-docker"],
|
||||
"versioning": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)\\.(?<build>\\d+)-[0-9a-f]+$"
|
||||
},
|
||||
{
|
||||
"description": "ADDED 2026-10-30. A CNPG Postgres MAJOR is a migration (new cluster + import), not an image bump; the dry run proposed 16.4 -> 18.6. Minors stay (and are blast-radius, above).",
|
||||
"matchPackageNames": ["ghcr.io/cloudnative-pg/postgresql"],
|
||||
@@ -349,6 +380,20 @@
|
||||
"automerge": false,
|
||||
"addLabels": ["needs-review"]
|
||||
},
|
||||
// OPERATOR DECISION 2026-10-30: these two never auto-merge, at ANY update type. Both sit after
|
||||
// the auto-merge rule (rule 1) so they override it; `needs-review` is added, not replaced.
|
||||
{
|
||||
"description": "ADDED 2026-10-30 (operator decision). The oauth2-proxy image, under the ONE depName every source of it emits: catalog/render.py OAUTH2_PROXY_IMAGE (custom regex manager), the rendered apps/wave-* sidecars and platform/crafty/deployment.yaml (kubernetes manager). WHY: since v7.13.0 oauth2-proxy validates REFRESHED OIDC sessions against the ACCESS token instead of the id_token (upstream CHANGELOG, v7.13.0), every sidecar sets --cookie-refresh, and that refresh path has never run against this Keycloak (the platform oauth2-proxy runs v7.15.5 WITHOUT --cookie-refresh). render-golden and render.py --selftest pass on the bump, but both check rendered text, not the binary, so a green gate is not evidence here.",
|
||||
"matchPackageNames": ["quay.io/oauth2-proxy/oauth2-proxy"],
|
||||
"automerge": false,
|
||||
"addLabels": ["needs-review"]
|
||||
},
|
||||
{
|
||||
"description": "ADDED 2026-10-30 (operator decision). Renovate's OWN version: the job `container.image` pin in .gitea/workflows/renovate.yaml (ghcr.io/renovatebot/renovate), and renovatebot/github-action should it ever be reintroduced (ci/lint.sh rejects it today). WHY: nothing re-validates renovate.json5 against the NEW version before it takes effect. The gate does not run renovate-config-validator, and this exact gap has bitten before: the action's default 39.x rejected this file (managerFilePatterns needs >= 40.2.0) and every run stopped at CONFIG_VALIDATION. Run the validator from the new tag, then merge.",
|
||||
"matchPackageNames": ["ghcr.io/renovatebot/renovate", "renovatebot/github-action"],
|
||||
"automerge": false,
|
||||
"addLabels": ["needs-review"]
|
||||
},
|
||||
{
|
||||
"description": "ADDED 2026-10-30. MAJORS WAIT FOR A TICK ON THE DEPENDENCY DASHBOARD instead of opening a PR. They are never auto-merged anyway, and with prConcurrentLimit 5 the dry run's 10 majors would hold every PR slot open indefinitely, starving the patch/minor/digest PRs that can merge on green.",
|
||||
"matchUpdateTypes": ["major"],
|
||||
@@ -365,6 +410,10 @@
|
||||
// ARE maintained (the first dry run proposed 35-apache and 29-dind; the suffix is kept).
|
||||
// CORRECTED 2026-10-30: quay.io/keycloak/keycloak was never a running reference — the operator
|
||||
// picks the server image — and the Keycloak version is now tracked via the operator URLs.
|
||||
// CORRECTED 2026-10-30 (feat/pin-pihole-jackett-plex): pms-docker, jackett and pihole are now
|
||||
// PINNED (1.43.4.10903-e5521bd8c, v0.24.2865-ls7, 2026.10.0 — the digests that were running) and
|
||||
// maintained via the versioning rules above. None of the three publishes "only latest": all three
|
||||
// publish immutable version tags, they were simply never referenced.
|
||||
// Separately, the HelmRelease minor-locking ranges (9 of 12, e.g. `>=88.0.0 <89.0.0` on
|
||||
// kube-prometheus-stack): THIS FILE USED TO SAY they cap Renovate at patches. They do not —
|
||||
// Renovate WIDENS the range (runs 353/354). The `range-widen` rule above keeps that a reviewed
|
||||
|
||||
@@ -112,10 +112,12 @@ A private repository with no `secretRef`, or an expired token. The message names
|
||||
### 4. The policy's range matches nothing
|
||||
`ImagePolicy` semver ranges come from each ServiceDefinition's `update.range` (for example
|
||||
`">=2024.0.0"` for pihole, `">=24.0.0"` for keycloak). A range that no published tag satisfies — or a
|
||||
repository that publishes only `latest` (this platform has `lscr.io/linuxserver/jackett:latest` and
|
||||
`plexinc/pms-docker:latest`) — leaves the policy with nothing to select. Remediation: fix the range in
|
||||
`catalog/<service>.yaml` and re-render; a `latest`-only image cannot participate in automation at all
|
||||
and should not carry a policy.
|
||||
repository that publishes only `latest` — leaves the policy with nothing to select. Remediation: fix
|
||||
the range in `catalog/<service>.yaml` and re-render; a `latest`-only image cannot participate in
|
||||
automation at all and should not carry a policy. (CORRECTED 2026-10-30: this page used to name
|
||||
`lscr.io/linuxserver/jackett` and `plexinc/pms-docker` as `latest`-only. Neither is — both publish
|
||||
immutable version tags (`v0.24.2865-ls7`, `1.43.4.10903-e5521bd8c`) — they were merely *deployed* as
|
||||
`:latest`. Both are now pinned to those tags and maintained by Renovate, not by an ImagePolicy.)
|
||||
|
||||
### 5. Egress is blocked for that namespace
|
||||
Default-deny everywhere (R-NET-01) means outbound registry access is an explicit allow. Cross-read
|
||||
|
||||
@@ -21,7 +21,8 @@ which is why this page exists: the loop is scripted and the reconcile is **force
|
||||
waited for. Measured end to end on 2026-09-29: **~2m45s**.
|
||||
|
||||
**An expired or wrong token does not break anything.** Verified against the image's own first-run
|
||||
script (and the copy inside `plexinc/pms-docker:latest`): a failed exchange gets a 403 with an empty
|
||||
script (and the copy inside the image then running as `plexinc/pms-docker:latest`; pinned to a version
|
||||
tag since 2026-10-30): a failed exchange gets a 403 with an empty
|
||||
body, `curl`'s status is never checked, the token `sed` yields empty, the `if` does not fire, there is
|
||||
no `else` and no `exit 1`, and the script (no `set -e`) exits 0. The pod comes up **unclaimed and
|
||||
serving**. So a burnt token costs you one more attempt, not an outage — and the repo deliberately
|
||||
@@ -186,4 +187,5 @@ When the claim does not take, in order of how often it is the answer here:
|
||||
Source of truth: `runbooks/plex-claim.md`. Index: `/runbooks/`
|
||||
Upstream behaviour cited here is from `plexinc/pms-docker` branch `master`,
|
||||
`root/etc/cont-init.d/40-plex-first-run` (s6-overlay v2), cross-checked against the same file inside
|
||||
the running `plexinc/pms-docker:latest` image.
|
||||
the image that was running at the time as `plexinc/pms-docker:latest` (the deployment has been pinned
|
||||
to a version tag, `catalog/plex.yaml` `source.image`, since 2026-10-30).
|
||||
@@ -371,8 +371,8 @@ verify".)
|
||||
2026-10-01, so this means a node changed — a rebuild, a BIOS reset, or a driver regression.
|
||||
**Fix the node.**
|
||||
|
||||
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
|
||||
mechanism this feature leans on with no commit in this repo.
|
||||
4. **The image changed under you.** See "The image-version caveat". A pms-docker version bump (a
|
||||
Renovate PR, possibly auto-merged) can remove the mechanism this feature leans on.
|
||||
|
||||
5. **The session was never transcoding.** Direct Play / Direct Stream never reaches the transcoder,
|
||||
so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a
|
||||
@@ -446,17 +446,21 @@ revert you):
|
||||
Re-adding the field later is the same edit in reverse. Nothing about the Plex-side toggle or the
|
||||
`/config` PVC is affected either way — do **not** delete the PVC as part of this.
|
||||
|
||||
## The floating-image caveat
|
||||
## The image-version caveat
|
||||
|
||||
Everything this page states about the image's behaviour — the `cont-init.d` hook that adds the app
|
||||
user to the device's group, the absence of `PLEX_PREFERENCE*`, the six `setPref` keys, the
|
||||
`HardwareAcceleratedCodecs` string — was read from digest **`sha256:e0ab27395614…`**
|
||||
(`plexmediaserver 1.43.4.10903`).
|
||||
|
||||
The deployment runs **`plexinc/pms-docker:latest` with `imagePullPolicy: Always`**. A silent pull can
|
||||
therefore change the behaviour this feature depends on **with no commit in this repo**, while the
|
||||
manifest still looks identical. Symptom to watch for: hardware transcoding that worked and quietly
|
||||
stopped, with no git change to point at.
|
||||
**Since 2026-10-30 that digest is pinned:** `catalog/plex.yaml` names
|
||||
`plexinc/pms-docker:1.43.4.10903-e5521bd8c`, which resolved to exactly `sha256:e0ab27395614…` on
|
||||
Docker Hub. Before that the deployment ran the floating `:latest` (implicitly `imagePullPolicy:
|
||||
Always`), and a silent pull could change this behaviour with no commit in the repo. That is no longer
|
||||
the failure mode: the image now changes only through a **Renovate PR** bumping that tag (patch/minor
|
||||
ones auto-merge on a green gate, and the gate does not exercise QuickSync). So the symptom to watch
|
||||
for is hardware transcoding that quietly stopped **after a `chore(deps)` pms-docker bump** — check
|
||||
`git log -- catalog/plex.yaml` first.
|
||||
|
||||
```
|
||||
infra/devcontainer/dev.sh run bash -c \
|
||||
|
||||
@@ -67,9 +67,11 @@ Work through these in order; the first three are by far the most likely.
|
||||
| Symptom in the log | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `GET /user returned no login — is GITEA_TOKEN valid?` and a CrashLoop | the credentials are still the committed **placeholders**, or the token was revoked | fill them in per `platform/pm-agent/pm-agent-credentials.sops.yaml.example` |
|
||||
| `ImagePullBackOff`, digest `sha256:0000…` | the image has never been published; the committed digest is the deliberate placeholder | CI publishes it, not a person: check the latest `images` workflow run on `main` (it fails loudly if the `REGISTRY_PUSH_TOKEN` secret is missing; re-run it with `workflow_dispatch`), then merge Renovate's digest-bump PR, which updates **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) together — but read the row below first |
|
||||
| `ImagePullBackOff`, digest `sha256:0000…` | (historical: before 2026-10-30 the committed digest was an all-zero placeholder) the image has never been published at the digest named | CI publishes it, not a person: check the latest `images` workflow run on `main` (it fails loudly if the `REGISTRY_PUSH_TOKEN` secret is missing; re-run it with `workflow_dispatch`), then merge Renovate's digest-bump PR, which updates **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) together — but read the row below first |
|
||||
| `ImagePullBackOff` with `x509: certificate is not valid for any names` | **diagnosed.** containerd pulls as a node process, resolves through the node's public upstreams, and gets the CNAME to the WAN IP — which this router does not hairpin, so it answers :443 with its own `CN=%D` placeholder certificate (no SANs at all). Nothing to do with the digest, the Gateway or the wildcard cert | the `/etc/hosts` pin for `gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` fixes it — the same pin the Keycloak issuer already has. Apply it with the project's own tooling, scoped to just that task: `cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`. Full evidence: `decisions/2026-10-06-pm-agent-node-resolves-gitea-to-wan.md` |
|
||||
| `ImagePullBackOff` with `401 Unauthorized` once TLS works | the pull is unauthenticated. An anonymous client gets nothing from this Gitea's registry, and no `imagePullSecret` exists anywhere in the tree | add one (a `dockerconfigjson`, SOPS-encrypted like `pm-agent-credentials`, referenced from the Deployment **and** the committed worker Job template) or allow anonymous reads of the package in Gitea — a posture decision, so yours |
|
||||
| `ImagePullBackOff` with `401 Unauthorized` / `unauthorized` | the pull credential is wrong, absent or scoped without `read:package`. The `pm-agent-registry` Secret exists and is referenced; its *value* is what starts as a placeholder | check the key names only, never values: `kubectl -n pm-agent get secret pm-agent-registry -o go-template='{{range $k, $v := .data}}{{$k}}{{"\n"}}{{end}}'` should list `.dockerconfigjson`. Then confirm the token carries `read:package`, that its account can read the org's packages (a team with the Packages unit — see the `.example`), and that the registry key inside the JSON is the bare host `gitea.home.keithjackson.dev` — with a scheme or a port it matches nothing and the pull goes out anonymously, which looks identical to a missing Secret. Steps: `platform/pm-agent/pm-agent-registry.sops.yaml.example` |
|
||||
| `GET /user` answers `403` / `token does not have at least one of required scope(s): [read:user]` at startup, then a CrashLoop | the token lacks `read:user` — Gitea 1.24.3 scopes the whole `/api/v1/user` group, and the dispatcher's first call is `GET /user` | mint a new token with all four scopes (`read:user`, `write:repository`, `write:issue`, `read:package`) and put it in **both** Secrets |
|
||||
| a worker Job is refused by admission (`pm-agent-job-shape`, `imagePullSecrets`) | `PM_WORKER_IMAGE` is a digest built before the template carried `imagePullSecrets` (PR #61), so its baked template lacks the entry admission requires | pin the agent-runner digest CI published after #61 merged (Renovate's bump PR); until then keep `PM_DRY_RUN=true` |
|
||||
| `PM_WORKER_IMAGE must be digest-pinned` | someone put a tag in the ConfigMap | restore the digest form; the digest comes from CI's publish (`.gitea/workflows/images.yaml` job summary) and Renovate's bump PR, never from a hand push |
|
||||
| `scope: ONLY issues labelled 'agent'` | `PM_REQUIRE_LABEL` is set; unlabelled issues are out of scope by configuration | label the issue, or clear the knob |
|
||||
| `cycle: 2 worker(s) active` and nothing new | at `PM_MAX_ACTIVE_WORKERS` | wait, or raise it (RAM is the binding constraint: each worker's limit is 2Gi) |
|
||||
|
||||
Reference in new issue
Block a user