Files
home-network-k3s-impl/platform/pm-agent/deployment.yaml
T
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

140 lines
6.8 KiB
YAML

---
# =============================================================================
# The Program Manager dispatcher. One long-running process that polls Gitea issues, asks the
# model what to do about each thread under the PM persona, posts the answer on the issue, and
# creates one worker Job per assignment. Program: infra/agent/pm-dispatch.py.
#
# ONE REPLICA, Recreate STRATEGY — AND THAT IS A CORRECTNESS CONSTRAINT, NOT A RESOURCE ONE.
# Two dispatchers polling the same repository would both see the same untouched thread in the
# same window and both act on it: two decisions, two comments, two worker Jobs on the same
# issue, two branches. The service holds no lock and needs none at one replica — the issue
# thread itself is the state (see pm-dispatch.py's header) and a single reader of it is
# consistent by construction. `Recreate` is what extends that to a rollout: the default
# RollingUpdate would briefly run the new pod alongside the old one, which is the two-reader
# case again, for a minute, every time the image changes.
#
# NO PVC, NO VOLUME CLAIM, NOTHING TO BACK UP. The dispatcher keeps no durable state: its
# trigger is "the newest thing in this thread was not written by me", answered from Gitea on
# every cycle. So a restart loses nothing, there is no recovery path to get wrong, and this
# workload creates no R-STATE-03 obligation.
# =============================================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: pm-agent
namespace: pm-agent
labels:
app.kubernetes.io/name: pm-agent
app.kubernetes.io/part-of: pm-agent
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: pm-agent
template:
metadata:
labels:
app.kubernetes.io/name: pm-agent
app.kubernetes.io/part-of: pm-agent
annotations:
# Roll the pod when its configuration changes. Without this, a ConfigMap edit that
# Flux applies leaves the running process on the OLD values indefinitely — the
# process reads its config once at startup — so the change would appear to have
# landed while changing nothing. (Same class of defect as the inert merged
# cilium-config: decisions/2026-10-05-cilium-roll-out-on-config-change.md.)
#
# 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"
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.
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: dispatch
# THE SAME IMAGE THE WORKERS RUN, at the same digest. They share the committed Job
# template and the env-name contract baked into it, so a mismatched pair would
# 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).
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/agent-runner tag=main
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
imagePullPolicy: IfNotPresent
command:
- /usr/local/bin/pm-dispatch
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
privileged: false
capabilities:
drop:
- ALL
envFrom:
# Every non-secret knob, so adding one is a ConfigMap edit plus a bump of the
# config-generation annotation above — not an edit in two places.
- configMapRef:
name: pm-agent-config
env:
# The two credentials, by NAME only (R-IDP-04). Never a literal, never `envFrom`
# over the whole Secret: the dispatcher needs exactly these two keys, and naming
# them is what makes the Secret's other contents (if it ever grows any) not
# reach this process.
- name: GITEA_TOKEN
valueFrom:
secretKeyRef:
name: pm-agent-credentials
key: gitea-token
- name: CLAUDE_CODE_OAUTH_TOKEN
valueFrom:
secretKeyRef:
name: pm-agent-credentials
key: claude-code-oauth-token
resources:
# Modest: the dispatcher is idle between polls, and its one expensive act (a
# single-turn model call with no tools) is bounded by PM_ISSUE_COOLDOWN_SECONDS.
# The real resource consumers are the worker Jobs, capped separately.
requests:
cpu: 50m
memory: 192Mi
limits:
cpu: 500m
memory: 512Mi
# No probes, deliberately. There is no port to probe and no health endpoint, and an
# exec probe would be a second execution path into a pod that runs model-authored
# decisions. The process is a loop that logs every cycle: liveness is answered by
# `kubectl logs`, and a crash is answered by the Deployment's own restart. A probe
# that cannot distinguish "polling normally" from "wedged" would only add a way to
# restart a healthy pod.
volumeMounts:
# The root filesystem is read-only, so HOME, TMPDIR, the Claude Code config
# directory and every cache live here (see the image's ENV block).
- name: workspace
mountPath: /workspace
- name: tmp
mountPath: /tmp
volumes:
- name: workspace
emptyDir:
sizeLimit: 512Mi
- name: tmp
emptyDir:
sizeLimit: 128Mi