Compare commits

...
Author SHA1 Message Date
jakman85 9db20e8ca0 Added the credentials
gate / gate (pull_request) Successful in 2m57s
gate / conformance (pull_request) Has been skipped
2026-10-31 11:11:22 -05:00
jakman85 ddd04d460e Merge pull request 'feat(pm-agent): pull credential + admission rule 7, first published digest pinned, dry-run first start, token scopes fixed' (#61) from feat/pm-agent-registry-pull-secret into main
gate / gate (push) Successful in 2m53s
images / images (push) Successful in 15s
gate / conformance (push) Has been skipped
Reviewed-on: #61
Reviewed-by: Keith Jackson <[email protected]>
2026-10-31 15:46:54 +00:00
jakman85 f0760d1024 Merge pull request 'feat(images): pin pihole, jackett and plex to the digests already running; Renovate follows them' (#94) from feat/pin-pihole-jackett-plex into main
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Reviewed-on: #94
2026-10-31 15:45:17 +00:00
jakman85 553d027f7c Merge branch 'main' into feat/pm-agent-registry-pull-secret
gate / gate (pull_request) Successful in 2m58s
images / images (pull_request) Successful in 34s
gate / conformance (pull_request) Has been skipped
2026-10-31 15:43:51 +00:00
jakman85andClaude Opus 5.5 183bca6897 feat(images): pin pihole, jackett and plex to the digests already running; Renovate follows them
gate / gate (pull_request) Successful in 2m57s
gate / conformance (pull_request) Has been skipped
pihole/pihole (no tag)               -> 2026.10.0               sha256:d3413471...
lscr.io/linuxserver/jackett:latest   -> v0.24.2865-ls7          sha256:4fcf3cc2...
plexinc/pms-docker:latest            -> 1.43.4.10903-e5521bd8c  sha256:e0ab2739...

Each tag was resolved against its registry and matched the pods' imageID, so the image is
unchanged (the pod template string does change, so Flux rolls the three workloads).

renovate.json5: regex versioning for jackett (-lsN) and plex (build-hex); jackett is looked
up on ghcr.io via overridePackageName, because lscr.io is capped at dockerMaxPages=20 pages
and jackett's 32k tags put the running one at #31,980 (dry run proposed nothing without it);
jackett weekly (13 builds in 12 days); pihole deliberately unheld, year rollover = major.
Stale "publishes only latest" prose fixed in renovate.json5 and three runbooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:38:07 -05:00
jakman85 ce849032d5 Merge pull request 'chore(deps): Pin dependencies' (#89) from renovate/pin-dependencies into main
gate / gate (push) Successful in 2m55s
images / images (push) Successful in 5s
gate / conformance (push) Has been skipped
Reviewed-on: #89
Reviewed-by: Keith Jackson <[email protected]>
2026-10-31 15:35:30 +00:00
renovate-bot b766130b85 Merge pull request 'chore(deps): Update gitea.home.keithjackson.dev/home-network/conformance-runner:main Docker digest to 65cc7c1' (#92) from renovate/gitea.home.keithjackson.dev-home-network-conformance-runner-main into main
gate / gate (push) Successful in 2m57s
gate / conformance (push) Has been skipped
2026-10-31 15:26:58 +00:00
renovate-bot 862773f31b Merge pull request 'chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to d435fab' (#93) from renovate/gitea.home.keithjackson.dev-home-network-toolbox-main into main
gate / gate (push) Successful in 2m55s
images / images (push) Successful in 47s
gate / conformance (push) Has been skipped
2026-10-31 08:25:11 +00:00
renovate-bot db4ba79a1c Merge pull request 'chore(deps): Update gitea.home.keithjackson.dev/home-network/agent-runner:main Docker digest to c4eba5b' (#90) from renovate/gitea.home.keithjackson.dev-home-network-agent-runner-main into main
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-10-31 08:22:14 +00:00
renovate-bot 0b6282aba4 chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to d435fab
gate / gate (pull_request) Successful in 2m57s
images / images (pull_request) Successful in 5s
gate / conformance (pull_request) Has been skipped
2026-10-31 08:16:11 +00:00
renovate-bot c6cfaf62b6 chore(deps): Update gitea.home.keithjackson.dev/home-network/conformance-runner:main Docker digest to 65cc7c1
gate / gate (pull_request) Successful in 2m58s
gate / conformance (pull_request) Has been skipped
2026-10-31 08:16:10 +00:00
renovate-bot 4ae648f6d3 chore(deps): Update gitea.home.keithjackson.dev/home-network/agent-runner:main Docker digest to c4eba5b
gate / gate (pull_request) Successful in 2m58s
gate / conformance (pull_request) Has been skipped
2026-10-31 08:16:08 +00:00
jakman85 a4d5206294 Merge pull request 'fix(renovate): never auto-merge the oauth2-proxy sidecar or Renovate's own version' (#87) from fix/renovate-hold-auth-and-self into main
gate / gate (push) Successful in 3m15s
gate / conformance (push) Has been skipped
renovate / renovate (push) Successful in 1m8s
Reviewed-on: #87
2026-10-30 08:21:22 +00:00
renovate-bot 47eb6afa02 chore(deps): Pin dependencies
images / images (pull_request) Successful in 1m41s
gate / gate (pull_request) Successful in 3m8s
gate / conformance (pull_request) Has been skipped
2026-10-30 08:20:40 +00:00
jakman85andClaude Opus 5.5 7f0cbc7ba2 feat(pm-agent): pin the first published agent-runner digest, start dry, and fix the token scopes
gate / gate (pull_request) Failing after 2m56s
images / images (pull_request) Successful in 32s
gate / conformance (pull_request) Has been skipped
Rebased #61 onto the CI image pipeline (#81). This commit makes the remaining in-git
blockers to going live go away:

- deployment.yaml and PM_WORKER_IMAGE pin images run 340's digest (sha256:c4eba5b3...,
  tag sha-523cd9118f95 = main), read from the registry. Renovate markers unchanged.
- PM_DRY_RUN=true and PM_REQUIRE_LABEL=agent for the first run. Dry run is required, not
  just cautious: the run-340 image's baked worker template predates imagePullSecrets, so
  admission would deny its Jobs until CI re-publishes after this merge and Renovate bumps.
- Token scopes: Gitea 1.24.3 scopes GET /api/v1/user (the dispatcher's first call) under
  read:user. The documented list was missing it. Now read:user, write:repository,
  write:issue, read:package everywhere.
- Org-package read documented as a team (Packages=Read, no repos), not just a scope.
- sops is toolbox-only; the .examples say so and give the in-place edit.
- runbook: key-name check no longer emits Secret values before the pipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:16:21 -05:00
jakman85andClaude Opus 5 a7eadd4054 feat(pm-agent): the kubelet's pull credential, and the seventh field admission had not read
The registry had two blockers. #59 fixed the first (nodes resolved the hostname to the
non-hairpinned WAN and failed TLS against the router's CN=%D cert). This is the second:
the pull is unauthenticated, and this registry refuses unauthenticated clients outright —
measured 2026-10-05, its anonymous token carries access: null and every path 401s.

pm-agent-registry is a dockerconfigjson Secret (SOPS, placeholders) referenced by
imagePullSecrets on the dispatcher Deployment and on the committed worker Job template. It
holds 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. Same one-value-in-two-files pattern as the Keycloak client
secret, documented in both .example files. The operator's part is the value plus one new
scope: read:package.

ADMISSION GAINED A SEVENTH RULE. imagePullSecrets is a pod-spec field 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; rule 7 forbids any other and any second one. Both halves are needed: a
foreach over an absent list yields no result.

The leverage is small and the policy says so: the image is digest-pinned, so a different
pull credential cannot change WHAT runs. Pinned anyway — 'only the committed shape' is the
premise, and the rule costs four lines.

kyverno test: 33 passed, 0 failed (was 28). Three new fixtures, each a real path: the
committed entry plus a second one, a different Secret, and none at all.

The gate also caught me shipping the Secret in plaintext — I never ran sops -e -i on the
placeholder. Recorded in the handoff entry, because the file looked finished.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-30 01:09:39 -05:00
33 changed files with 813 additions and 89 deletions

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+10 -10
View File
@@ -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 }}
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -62,7 +62,7 @@ spec:
spec:
containers:
- name: pihole
image: pihole/pihole
image: pihole/pihole:2026.10.0
ports:
- containerPort: 80
env:
+1 -1
View File
@@ -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:
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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.
+101
View File
@@ -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.
+12 -9
View File
@@ -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.
+2
View File
@@ -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"
+1 -1
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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.
+23 -10
View File
@@ -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:c4eba5b3c508bf456a03391ac76f8275e7ea28a829da1450d99f6f6f25c6eb42"
# --- 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 —
+10 -4
View File
@@ -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: "2"
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:c4eba5b3c508bf456a03391ac76f8275e7ea28a829da1450d99f6f6f25c6eb42
imagePullPolicy: IfNotPresent
command:
- /usr/local/bin/pm-dispatch
+6
View File
@@ -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"}}}'
+35
View File
@@ -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"],
@@ -379,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
+6 -4
View File
@@ -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
+4 -2
View File
@@ -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).
+11 -7
View File
@@ -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 \
+4 -2
View File
@@ -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) |