Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5 7fa5e84d25 feat(pm-agent): the kubelet's pull credential, and the seventh field admission had not read
gate / gate (pull_request) Failing after 1m53s
gate / conformance (pull_request) Has been skipped
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-06 01:03:54 -05:00
jakman85 523f482957 Merge pull request 'fix(node): pin gitea to the Gateway VIP — the failing certificate was the router's, not the Gateway's' (#59) from docs/pm-agent-registry-pull-prereq into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #59
2026-10-06 05:51:22 +00:00
jakman85andClaude Opus 5 90a79d29d3 docs(node): name the exact Ansible command that applies the pin, not 'run the play'
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
Node configuration in this project is Ansible's — declared in the inventory, reviewed,
version-controlled — and for prod it is the ONLY surface: infra/PROD-PROVISIONING.md
records prod as 3x bare metal with the dev OpenTofu -> Proxmox -> cloud-init path
explicitly not applying, so there is no tofu/cloud-init half to keep in parity for an
/etc/hosts pin.

The pin was already in the right place; what the docs lacked was the command. All four
places now carry the scoped, idempotent one:

    cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
      -e k3s_hosts=k3s_prod --tags hosts

--tags hosts runs ONLY the /etc/hosts task, so applying this pin cannot drift any other
baseline setting.

Also verified the mechanism is not inert, which is the failure mode this repo keeps
meeting: roles/node_baseline/tasks/main.yml renders node_baseline_hosts_pins into a marked
blockinfile with backup: true, and state: absent when the list empties — so a removed
entry really does leave the node.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:44:26 -05:00
jakman85andClaude Opus 5 523a463fd7 fix(node): pin gitea to the Gateway VIP — containerd was resolving it to the non-hairpinned WAN
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
The pm-agent image pull failed TLS verification, and the previous entry on this branch
recorded it honestly as not diagnosed. Diagnosed now, and it is not about the image.

containerd pulls as a NODE process, so it resolves through node_baseline_dns_servers (the
LAN gateway, deliberately not PiHole) and gets the public answer: a CNAME to
wan.home.keithjackson.dev -> 136.51.115.252, measured via both 1.1.1.1 and 8.8.8.8. This
router does not hairpin, so WAN:443 answers with its own placeholder certificate —
measured with the right SNI from the workstation:

    subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
    No extensions in certificate

No extensions means no subjectAltName, which is exactly 'certificate is not valid for any
names'. Meanwhile 192.168.2.20 IS the shared Gateway's VIP and its https listener serves
the real Let's Encrypt *.home.keithjackson.dev. The Gateway, the wildcard cert and the
digest were never implicated — only the node's answer.

This file already documents the identical failure for the Keycloak issuer, measured from
k3s-prod-2/-3 on 2026-10-03 (curl exit 60, ssl_verify_result=18), and already carries the
mechanism that fixes it. gitea is simply the second node process to need it; containerd is
a node process and nobody had added it. An operator must run the node_baseline play.

Also records, where an operator reads them, that the agent authenticates to Gitea with a
local account and a scoped PAT by decision — and why the federated alternative is not
merely unchosen but unavailable: Gitea implements only the Authorization Code Grant and
validates no third-party OIDC token, so a Keycloak service-account token cannot
authenticate to its API or to git-over-HTTP.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:35:18 -05:00
jakman85andClaude Opus 5 a209a618fd fix(pm-agent): the agent is IN the cluster — one git host, the cluster-local service name
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
An in-cluster consumer reaches a service by its *.svc.cluster.local name, which is by
design a different path from how a client outside the cluster reaches the same service.
pm-worker's host allowlist accepted TWO values: the cluster-local name and the external
hostname gitea.home.keithjackson.dev. The second is removed.

It was never needed — nothing sets it — it would route in-cluster traffic out through the
Gateway and back for no reason, and as an ACCEPTED value it widened the allowlist to a
host reachable over the namespace's world:443 egress rule, which is precisely the
injection shape the allowlist exists to refuse.

probe-validation.sh now pins that: 20 refusals, including the external hostname, the
cluster-local name over https, and the cluster-local name on the wrong port.

Also corrects a remark in the handoff entry: I cited cluster-vars' GATEWAY_VIP
(192.168.1.53) as suspicious next to the nodes' 192.168.2.x. That was noise — those base
cluster-vars values are dev-era leftovers for a decommissioned cluster and say nothing
about prod. None of the measured facts depended on it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:40:55 -05:00
jakman85andClaude Opus 5 6253d546f8 docs(pm-agent): the registry cannot be pulled from yet — name both prerequisites where the operator reads them
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
pm-agent reconciled exactly as designed after #57: Flux applied main@sha1:95597bd to
platform-config, pm-agent and apps-wave-0 (all Ready), the ClusterPolicy
pm-agent-job-shape is live with Enforce + failurePolicy Fail and all six rules, SOPS
decryption works through the new overlay patch (checked by key names and value lengths,
never values), and pm-agent-worker really is bound to nothing.

Measuring it turned up two prerequisites for the NEXT operator step that nothing in this
repository declares, and that #57's handoff could not have known — it listed the image
pull as unverified, and that is exactly where it bit:

 - the nodes cannot verify the registry's TLS certificate (containerd: 'x509: certificate
   is not valid for any names'), while the same name serves a healthy Let's Encrypt
   wildcard from the LAN. A correct digest fails identically;
 - an unauthenticated client gets nothing from this Gitea's registry (its anonymous token
   carries no access scope) and no imagePullSecret exists anywhere in the tree.

Not specific to pm-agent: conformance-runner shares the registry path and has never been
pulled either. The dispatcher is the first workload in the cluster ever to reference it.

Documentation only — no manifest, no policy, no code. Both fixes need an operator action
and one of them is a posture decision, so the docs present both rather than pick.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:22:52 -05:00
19 changed files with 780 additions and 20 deletions

No files matched your search

@@ -0,0 +1,127 @@
---
date: 2026-10-05
branch: docs/pm-agent-registry-pull-prereq
title: pm-agent reconciled exactly as designed — and is the first workload ever to pull from the in-cluster registry, which does not work yet
---
## What changed
Documentation only. No manifest, no policy, no code.
PR #57 merged and `pm-agent` reconciled. I measured what landed, and the measurement turned
up **two prerequisites for the next operator step that nothing in this repository
declares** — and that the handoff entry for #57 could not have known, because it listed the
image pull as unverified and that is exactly where it bit.
So this entry records the post-merge measurement, and
`platform/pm-agent/README.md`, `docs/plans/pm-agent-issue-to-pr.md` and
`runbooks/pm-agent.md` now name both prerequisites where they say "build, push, pin the
digest". Before this, that step read as one action; it is three, and the two new ones are
not an agent's to perform.
**The finding is not specific to pm-agent.** `conformance-runner` has the identical
registry path (`gitea.home.keithjackson.dev/home-network/...`), the identical "operator
pushes, a digest goes in a PR" model, and has never been pulled either — so the same two
blockers sit in front of the L2/L3 conformance wiring. Measured: **no pod anywhere in the
cluster references that registry** except the pm-agent dispatcher, which is therefore the
first workload ever to try it.
## What I verified
Read-only, through the pinned toolbox, against the live prod cluster.
**The merge reconciled, completely and exactly as designed.**
- `GitRepository/flux-system` artifact revision: `main@sha1:95597bd6883637…` — the merge
commit. `platform-config`, `pm-agent` and `apps-wave-0` all report that same
`lastAppliedRevision` with `Ready=True`. Compared field to field, per CLAUDE.md §2b,
rather than assumed from the merge.
- **The security boundary is live.** `ClusterPolicy/pm-agent-job-shape`:
`validationFailureAction=Enforce`, `failurePolicy=Fail`, `Ready=True`, and all six rules
present (`worker-must-be-the-committed-shape`,
`worker-must-be-one-container-with-no-args`,
`worker-volumes-must-be-workspace-or-tmp-emptydirs`,
`worker-may-not-declare-init-or-ephemeral-containers`,
`worker-env-names-must-be-the-committed-set`, `worker-may-not-use-envfrom`). This is the
check that mattered most: a ClusterPolicy added to the wrong kustomization list ships
green and never reaches the cluster, which is what happened to the conformance boundary
on 2026-09-29.
- Namespace contents: both ServiceAccounts, `pm-agent-config` (17 keys), the
`pm-agent-credentials` Secret, the `default-deny` NetworkPolicy, and both
CiliumNetworkPolicies with `VALID=True`.
- **SOPS decryption works through the new overlay patch.** Checked by KEY NAMES and VALUE
LENGTHS only, never values (CLAUDE.md §6): keys `gitea-token`, `claude-code-oauth-token`,
lengths 76 and 52 bytes — which are the base64 lengths of the committed placeholder
strings, so the Secret holds the decrypted placeholders and not ciphertext. A
ciphertext apply succeeds silently and would have left `ENC[AES256_GCM,…]` values an
order of magnitude longer.
- **`pm-agent-worker` really is bound to nothing.** The namespace has exactly one
RoleBinding (`pm-agent` → Role `pm-agent`, subject `ServiceAccount/pm-agent`), and no
ClusterRoleBinding anywhere names either pm-agent ServiceAccount.
- `gitea-allow-pm-agent-ingress` is applied in the `gitea` namespace, matching
`io.kubernetes.pod.namespace: pm-agent`.
- The dispatcher is `ImagePullBackOff` on the all-zero placeholder digest — the designed,
visible failure.
**The two prerequisites, measured.**
1. **The nodes cannot verify the registry's TLS certificate.** containerd's error on the
pull is not about the digest:
failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:000…":
tls: failed to verify certificate: x509: certificate is not valid for any names,
but wanted to match gitea.home.keithjackson.dev
"not valid for **any** names" means the certificate presented to the node carries no
matching SAN at all. From the operator's workstation the same name is healthy: it
resolves to `192.168.2.20` and serves a Let's Encrypt certificate
`CN=*.home.keithjackson.dev` with SANs `*.home.keithjackson.dev, home.keithjackson.dev`
— and `Certificate gateway/wildcard-home` is `Ready=True` for exactly those names. So
the certificate that *should* be served is fine, and what the NODE reaches is not what
the workstation reaches. **Correcting the digest will not clear this.**
2. **Anonymous pulls from this registry are refused.** Not inferred from a bare 401 — that
would have been wrong, because this Gitea answers 401 for *everything* anonymously:
`/v2/home-network/agent-runner/manifests/latest`, `/v2/home-network/conformance-runner/…`,
a randomly-named nonexistent repository, and `nosuchowner/nosuchimage` all return 401,
and `/api/v1/packages/Home-Network` returns `{"message":"token is required"}`. The
registry *does* issue a token to an anonymous client, but its `access` claim is `null` —
no pull scope. So the conclusion that holds is the simple one: **an unauthenticated
client gets nothing from this registry**, so a pull with no credential cannot succeed.
And `grep` over the tree finds **no `imagePullSecrets` anywhere** — not in
`platform/pm-agent/deployment.yaml`, not in `infra/agent/worker-job-template.yaml`, not
in `conformance/incluster/`.
## What I did NOT verify
- **Why the node sees a nameless certificate.** I measured that it does, and that the LAN
does not. I did not diagnose it: I did not read the nodes' resolver or
`/etc/rancher/k3s/registries.yaml` (node files, not reachable read-only through the API),
and I did not check whether the node resolves the name to the same `192.168.2.20` the
workstation does or to something else.
**A correction to a remark I first wrote here:** I cited
`cluster-vars`'s `GATEWAY_VIP: 192.168.1.53` as suspicious next to the nodes'
`192.168.2.x`. That was noise — the operator confirms those base `cluster-vars` values are
**dev-era leftovers for a cluster that is decommissioned**, so the 192.168.1.x addresses in
that file say nothing about prod and should not be read as evidence about anything. The
measured facts stand on their own and none of them depended on that knob.
- **Whether the package would be pullable once authenticated.** Nothing has ever been
pushed to that path, so "a credential is sufficient" is untested. The only thing
established is that no credential is insufficient.
- **Which fix the operator wants.** Two routes, and they are not equivalent:
(a) an `imagePullSecret` — a `dockerconfigjson` holding a Gitea token, SOPS-encrypted
like `pm-agent-credentials`, referenced from the Deployment *and* from the committed
worker Job template; note the Job template's shape is pinned by admission, and
`imagePullSecrets` is a field that policy does not currently constrain, so adding one is
a template change that needs a deliberate look at whether the policy should pin it too.
(b) allowing anonymous reads of that package in Gitea — no manifest change at all, but it
makes the image world-readable to anything that can reach the host.
I have implemented neither: both need an operator action I must not take, and (b) is a
posture decision. The docs now present both.
- **Everything the #57 entry listed as unverified about RUNTIME remains unverified.** The
image has still never been built or pushed. No dispatcher has authenticated to Gitea, no
issue has been triaged, no worker Job has been created — so the admission policy, though
now provably applied and Enforce, has still never refused or admitted a real Job. The
network policies have not carried traffic.
- **I did not re-run the merge gate against `main` after the merge.** This branch's own gate
run is the evidence for this change; the merged state's is CI run on PR #57.
@@ -0,0 +1,126 @@
---
date: 2026-10-06
branch: docs/pm-agent-registry-pull-prereq
title: The nodes resolve gitea to the WAN, so the failing certificate is the router's — the second node process to need the issuer pin
---
## What changed
The image pull for `pm-agent` failed TLS verification, and the previous entry on this branch
honestly recorded it as **not diagnosed**. The operator asked for the diagnosis before any
registry design. This is it, and the fix turns out to be a pattern this repository already
has.
`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` now pins
`gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `node_baseline_hosts_pins`, beside the
Keycloak issuer pin that is there for the identical reason.
**It reaches the nodes the way every other node setting in this project does — through
Ansible, from the inventory.** Node configuration is Ansible's (and, for the dev VMs,
OpenTofu's); nothing here touches a node by hand. For prod specifically, Ansible is the
*only* surface: `infra/PROD-PROVISIONING.md` records that prod is 3× bare metal and that the
dev path (OpenTofu → Proxmox VM → cloud-init) does not apply, so there is no
cloud-init/tofu half to keep in parity for this. The one scoped, idempotent command:
cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
-e k3s_hosts=k3s_prod --tags hosts
`--tags hosts` runs ONLY the `/etc/hosts` task, so applying this pin cannot drift any other
baseline setting. Running the play is an operator action (it has become/root on prod), but
the configuration itself is declared, reviewed and version-controlled — not typed on a node.
The docs that described this as an undiagnosed blocker now describe the cause and the fix:
`platform/pm-agent/README.md`, `runbooks/pm-agent.md` (its symptom row now names the pin and
the play), and `docs/plans/pm-agent-issue-to-pr.md`.
Also recorded, in the two places an operator reads when creating the credential: **the agent
authenticates to Gitea with a local account and a scoped PAT, by decision** (operator,
2026-10-06), not with a federated identity. Written down because the alternative looks
obviously better until you check whether it is possible — see below.
## What I verified
Every step measured, read-only, in this order.
**The failing certificate is the router's, not the Gateway's.**
- containerd's error names TLS, not the digest: `tls: failed to verify certificate: x509:
certificate is not valid for any names, but wanted to match gitea.home.keithjackson.dev`.
"not valid for **any** names" means the presented certificate carries no SANs at all.
- `192.168.2.20` is the shared Gateway's own LoadBalancer VIP
(`gateway/cilium-gateway-shared`, from the Cilium pool `192.168.2.20-30`), and its `https`
listener serves `*.home.keithjackson.dev` with `wildcard-home-tls`. From the operator's
workstation the name resolves there and serves the real Let's Encrypt
`CN=*.home.keithjackson.dev` with correct SANs. **So the certificate and the Gateway are
healthy.**
- **The node asks a different resolver and gets a different answer.** Prod's
`node_baseline_dns_servers` is the LAN gateway, not Pi-hole — deliberately, as that file
explains. Public DNS for this zone is a CNAME to `wan.home.keithjackson.dev`: measured via
both `1.1.1.1` and `8.8.8.8`, `gitea.home.keithjackson.dev` →
`wan.home.keithjackson.dev.` → `136.51.115.252`, and the apex answers the same.
- **That address serves a certificate with no names.** Measured from the workstation against
the WAN address with the right SNI:
subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
issuer=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
No extensions in certificate
No extensions means no `subjectAltName`, which is exactly "not valid for any names". The
`CN=%D` is the router's placeholder.
**This repository had already measured the same thing, for Keycloak, and I did not need to
re-derive it.** `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` says, above the pin:
"Since platform/gateway/gateway.yaml set an external-dns `target`, every public name is a
CNAME to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from
the LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18)." Same cause, same symptom,
one name later. The pin exists precisely for node processes that must reach the Gateway
directly; containerd is one, and nobody had added it.
**The parity the pin has to satisfy:** `192.168.2.20` equals the live
`GATEWAY_VIP`, and `k3s_base_domain` equals the live `BASE_DOMAIN` — both read from the
cluster, not from the comment that claims prod serves `prod.home.keithjackson.dev` (it does
not; `flux-system/cluster-vars` reports `BASE_DOMAIN=home.keithjackson.dev` and
`GATEWAY_VIP=192.168.2.20`, and Ansible's `k3s_base_domain` is `home.keithjackson.dev`).
That comment is stale history.
**The credential decision, and why the obvious alternative is not available.** Gitea's API
accepts basic auth, PATs, SSH signatures and its own OAuth2 bearer tokens; as an OAuth2
provider Gitea implements **only the Authorization Code Grant** (plus PKCE/OIDC), so there
is no client-credentials grant, and nothing in Gitea validates a token issued by a
third-party OIDC provider. A Keycloak service-account token therefore cannot authenticate to
Gitea's API or to git-over-HTTP. Read from Gitea's own documentation
(`docs.gitea.com/development/api-usage`, `/development/oauth2-provider`), not assumed.
**Gate:** `./ci/gate.sh` in the pinned toolbox from a §0 worktree — see the branch's run; rc 3
with `gitea-settings` the only stage that did not run.
## What I did NOT verify
- **That the pin fixes the pull.** It is IaC, and nothing has applied it: `/etc/hosts` on the
nodes is written by the `node_baseline` play's `hosts`-tagged task, which is an operator
action. I did confirm the mechanism is not inert — `roles/node_baseline/tasks/main.yml`
renders `node_baseline_hosts_pins` into a marked `blockinfile` with `backup: true`, and
`state: absent` when the list empties, so a removed entry really leaves the node. Until that runs
and an image exists, "the node now reaches the Gateway" is a design claim. The evidence
that it *will* is the Keycloak pin working for the same cause — which I also did not
re-measure.
- **Why public DNS answers at all for an `internal` service.** `catalog/gitea.yaml` sets no
`exposure`, so it inherits `internal`, and the renderer contract says an internal service's
HTTPRoute carries `external-dns.alpha.kubernetes.io/controller: none` and publishes no
public record. Yet `gitea.home.keithjackson.dev` resolves publicly to the WAN CNAME. I did
not chase that: it may be a zone-level wildcard rather than a published record, and it did
not change the diagnosis. It is worth someone's attention, because "internal services
publish no public DNS" is an L0-checked claim and this looks like a case where the *name*
resolves regardless of what the renderer withheld.
- **Registry authentication.** Unchanged and still open: an unauthenticated client gets
nothing from this registry, and no `imagePullSecret` exists anywhere in the tree. With the
credential model now decided (local Gitea accounts with PATs) the shape is a
`dockerconfigjson` for an account whose token carries `read:package`, referenced from the
Deployment and the committed worker Job template — and the worker Job template's shape is
pinned by admission, so whether the policy should also pin `imagePullSecrets` deserves a
deliberate look rather than a quiet addition. Not built: minting that token is an operator
action, and I did not want to guess at a second credential's scope.
- **Anything about runtime.** The image still does not exist. No dispatcher has
authenticated, no issue has been triaged, no worker Job has been created, and the admission
policy — live, `Enforce`, six rules — has still never admitted or refused a real Job.
@@ -0,0 +1,88 @@
---
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.
+16 -3
View File
@@ -233,9 +233,22 @@ about runtime behaviour in this document is a design claim, not a measurement.
## Follow-ups, in the order they are worth doing
1. **Build, push, pin.** Operator runs `infra/agent/build-image.sh`, pushes, and one PR
replaces both placeholder digests. Expect the build to need iteration: the nodejs module
stream and the npm install have never run against this base.
1. **Make the registry pullable, THEN build, push, pin.** Measured after the merge, and
neither half is declared anywhere in this repository
(`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`):
(i) the nodes resolved the registry hostname to the WAN IP and failed TLS against the
router's `CN=%D` placeholder certificate — **diagnosed and fixed** by pinning
`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 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. Operator runs `infra/agent/build-image.sh`, pushes, and one PR replaces both
placeholder digests. Expect the build itself to need iteration too: the nodejs module
stream and the npm install have never run against this base. The same two blockers sit 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"
+12 -2
View File
@@ -90,9 +90,19 @@ esac
# pointing at an external host would be REACHABLE: it could clone a repository that is not
# this one and push this repository's work somewhere else. The allowlist is baked in here,
# where changing it requires rebuilding the image whose digest admission pins.
#
# ONE ENTRY, AND IT IS THE CLUSTER-LOCAL SERVICE NAME. This agent runs INSIDE the cluster, so
# it reaches Gitea the way every other in-cluster consumer does — `*.svc.cluster.local` —
# which is by design a different path from how a client outside the cluster reaches the same
# service. The external hostname `gitea.home.keithjackson.dev` used to be a second accepted
# value here; it is removed deliberately. It was never needed (nothing sets it), it routes
# in-cluster traffic out through the Gateway and back for no reason, and as an ACCEPTED value
# it widened this allowlist to a host reachable over the `world:443` egress rule — which is
# the exact shape of injection the allowlist exists to refuse. The cluster-local name also
# depends on none of public DNS, Cloudflare, the Gateway or the certificate.
case "${PM_GITEA_URL}" in
http://gitea.gitea.svc.cluster.local:3000|https://gitea.home.keithjackson.dev) ;;
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not an allowed git host (see this script's header)" ;;
http://gitea.gitea.svc.cluster.local:3000) ;;
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not the allowed in-cluster git host (see this script's header)" ;;
esac
[[ "${PM_GITEA_OWNER}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]] || die "PM_GITEA_OWNER is malformed"
[[ "${PM_GITEA_REPO}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] || die "PM_GITEA_REPO is malformed"
+13 -3
View File
@@ -86,12 +86,22 @@ refuses "branch equal to the base branch" "must not be the base branch" \
# --- where code comes FROM and goes TO -------------------------------------
# The namespace's egress policy permits tcp/443 to the world (the model needs it), so an
# injected host would be REACHABLE. These are the cases that matter most.
refuses "a redirected git host" "is not an allowed git host" \
refuses "a redirected git host" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://evil.invalid
refuses "a real, reachable, WRONG git host" "is not an allowed git host" \
refuses "a real, reachable, WRONG git host" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://github.com
refuses "the right host on a smuggled path" "is not an allowed git host" \
refuses "the right host on a smuggled path" "is not the allowed in-cluster git host" \
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000/../..
# This agent is IN the cluster, so it reaches Gitea by its cluster-local service name. The
# EXTERNAL hostname is refused too — it used to be a second accepted value, and as an
# accepted value it widened this allowlist to a host reachable over the `world:443` egress
# rule, which is the injection shape the allowlist exists to refuse.
refuses "this Gitea's EXTERNAL hostname" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://gitea.home.keithjackson.dev
refuses "the cluster-local name over https" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://gitea.gitea.svc.cluster.local:3000
refuses "the cluster-local name, wrong port" "is not the allowed in-cluster git host" \
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:8080
refuses "a malformed owner" "PM_GITEA_OWNER is malformed" \
'PM_GITEA_OWNER=../other-org'
refuses "a malformed repository" "PM_GITEA_REPO is malformed" \
+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
@@ -327,6 +327,29 @@ node_baseline_hosts_pins:
- ip: "192.168.2.20"
names:
- "keycloak.{{ k3s_base_domain }}"
# gitea: the SECOND node process to need this pin, for the identical reason, found
# 2026-10-06. containerd pulls images as a NODE process, so it resolves through the
# public upstreams above and gets the CNAME to wan.home.keithjackson.dev — the WAN IP,
# which this router does not hairpin. The pull then fails on the router's own
# placeholder certificate rather than on anything to do with the image:
#
# failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:…":
# tls: failed to verify certificate: x509: certificate is not valid for any names,
# but wanted to match gitea.home.keithjackson.dev
#
# Measured from the workstation the same day, against the WAN address with that SNI:
# subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
# No extensions in certificate <- no SANs at all, hence "not valid for ANY names"
# while the same name via PiHole answers 192.168.2.20 and serves the real
# Let's Encrypt *.home.keithjackson.dev. So the certificate and the Gateway are fine;
# only the node's ANSWER is wrong. Same root cause, same fix, as the issuer pin above.
#
# WHO NEEDS IT: any workload whose image lives in the in-cluster Gitea registry —
# today platform/pm-agent (agent-runner) and conformance/incluster
# (conformance-runner), neither of which has ever been pulled. A pull is still
# UNAUTHENTICATED after this pin, which is a separate thing to settle; this only makes
# the node reach the right endpoint.
- "gitea.{{ k3s_base_domain }}"
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
@@ -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
+74 -11
View File
@@ -22,6 +22,25 @@ requires.
---
## Why the agent authenticates to Gitea with a local account and a PAT
Not Keycloak, and that is a decision rather than an omission (operator, 2026-10-06): the
agents only ever talk to Gitea, so a local Gitea account with a scoped personal access token
is the whole requirement, and it is drastically simpler than federating a machine identity.
It is also what Gitea can actually do. Keycloak service accounts were considered and are not
available for this: Gitea's API accepts basic auth, PATs, SSH signatures and **its own**
OAuth2 bearer tokens, and as an OAuth2 provider Gitea implements **only the Authorization
Code Grant** — there is no client-credentials grant, and nothing in Gitea validates a token
issued by a third-party OIDC provider. So a Keycloak service-account token cannot
authenticate to Gitea's API or to git-over-HTTP at all. Keycloak remains what it is for human
access: the browser login source registered on Gitea (`catalog/gitea.yaml`).
What still matters, and is enforced by the hand-off rather than by Keycloak: **the account
must not be an administrator** — `infra/gitea/approval-policy.json` authorises
`/approve-override` on live repository permission alone, so an admin-permission bot could
waive the sensitive-path human review on its own pull request.
## It does nothing at all until an operator does two things
Both are deliberate, and both fail **visibly** rather than silently:
@@ -31,12 +50,54 @@ Both are deliberate, and both fail **visibly** rather than silently:
`infra/agent/build-image.sh` (which verifies it and refuses to push), push it as the
operator, then update **both** places with the digest `podman push --digestfile` returned,
in one pull request. Until then: `ImagePullBackOff`.
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?`.
**Two things have to be true before any digest will pull, and neither is declared in this
repository.** Both were measured after PR #57 merged — this service is the first workload
in the cluster ever to reference the in-cluster registry, so nothing had exercised the
path. Details and the exact commands:
[`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`](../../decisions/2026-10-05-pm-agent-registry-pull-prereq.md).
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
*node* process, so it resolves through the node's public upstreams
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
IP — which this router does not hairpin; it answers :443 with its own placeholder
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
and the digest were never implicated. The fix is the pin this repository already uses
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the
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 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 `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 push-then-pin model,
never pulled either.
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 three scopes: `read:package` (the pull), `write:repository` (clone,
branch push, pull request) and `write:issue` (issue comments). 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.
@@ -96,9 +157,10 @@ Job from a committed template. **The model's output is data, never a command.**
| **cannot** | merge anything — the gate is the approver, and the service makes no merge call |
| **cannot** | reach the Kubernetes API: no ServiceAccount token is mounted, the `pm-agent-worker` identity is bound to nothing, and the egress policy does not include the API server. `kubectl` is on its `PATH` (the gate's toolchain needs it) and is inert |
| **cannot** | reach anything except this Gitea and tcp/443 — no package registry, no GitHub |
| **cannot** | clone from or push to any host but this Gitea: `pm-worker` validates `PM_GITEA_URL` against an allowlist baked into the image |
| **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
@@ -144,14 +206,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.
+4
View File
@@ -51,6 +51,10 @@ spec:
pm-agent.homelab.keithjackson.dev/config-generation: "1"
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.
+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.
@@ -51,6 +51,15 @@
# (2) MINT THE GITEA TOKEN as that user (log in as `pm-agent`, or use the admin API to
# create a token for it): Settings -> Applications -> Generate New Token.
#
# A LOCAL GITEA ACCOUNT WITH A PAT IS THE DECIDED MODEL (operator, 2026-10-06), not
# Keycloak. The agents only ever talk to Gitea, so this is the whole requirement and it
# is far simpler than federating a machine identity. It is also the only thing that
# works: Gitea's API accepts basic auth, PATs, SSH signatures and its OWN OAuth2 bearer
# tokens, and as a provider Gitea implements only the Authorization Code Grant — no
# client-credentials grant, and nothing that validates a third-party OIDC token. A
# 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`.
#
@@ -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:4OJS0P/oTcaXYuqlrc/936l+5/ZSqA9dlNMPx8HkDX9zmQ4q1qIumYmlXYMbcVm2c2byeYt0II0mGVLVVNOBjb5jgDB4vjyhSLN4HhoYBxgB+//hNcYe+uobmrdJTBJLEbd+lXamf1vfsEUTmLL7I4VQ2l/mjJbvqu8p2YNw25Ska9iO9mkxCzjOTfRdzMD0Pn1Z09fjo8GpYVo=,iv:AEysZLEqrybR+3l9kReHEeTbVFApzKPb8kAkibEYerE=,tag:TgE7T1zasye1bsTZ2BTFrA==,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-06T06:01:43Z"
mac: ENC[AES256_GCM,data:zwmmMbM9Me7wE8EOvrKD6hEMLVHNCxI85FHvMjtRcHHm+mtdACjofDNfpynFVc1xFMMchcoOfipxEfAAJcwbH0FtfLeZq6M+XNHAf81qIk3NJxrS5Kgi/9FXZJAAwhqo/eced89Uozq8ntwt+mgxOuyjMds5z4h/VfN7/RmsWHg=,iv:VlvAEqozM9VV50vVx7r5aq3BdaaLLubhHJyHt8SSgS0=,tag:4ji7UqfRfm9P+qJrezFpmw==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -0,0 +1,88 @@
# =============================================================================
# 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 three scopes and using it in BOTH Secrets:
#
# read:package <- NEW, what the kubelet needs to pull
# write:repository <- the agent's clone / branch push / pull-request create
# write:issue <- the agent's issue comments
#
# 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:
#
# 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"}}}'
+3 -1
View File
@@ -67,7 +67,9 @@ 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 pushed; the committed digest is the deliberate placeholder | `infra/agent/build-image.sh`, operator pushes, then one PR updates the digest in **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) |
| `ImagePullBackOff`, digest `sha256:0000…` | the image has never been pushed; the committed digest is the deliberate placeholder | `infra/agent/build-image.sh`, operator pushes, then one PR updates the digest in **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) — 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` / `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 jsonpath='{.data}' \| tr ',' '\n' \| cut -d'"' -f2` should list `.dockerconfigjson`. Then confirm the token carries `read:package` 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` |
| `PM_WORKER_IMAGE must be digest-pinned` | someone put a tag in the ConfigMap | use the digest from `podman push --digestfile` |
| `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) |