Files
home-network-k3s-impl/infra/agent/worker-job-template.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

190 lines
9.4 KiB
YAML

---
# =============================================================================
# worker-job-template.yaml — the ONE Job shape the PM dispatcher may create.
#
# WHERE THIS LIVES AND WHY. It is baked into the agent-runner image at
# /etc/pm-agent/worker-job-template.yaml (see the Containerfile), NOT mounted from a
# ConfigMap. A ConfigMap would be editable in-cluster by anyone who can patch it, and this
# file is the thing the dispatcher's RBAC + the admission policy are jointly protecting:
# the template IS the privilege boundary's shape. Baking it means changing it requires
# rebuilding and re-pushing an image, whose digest the policy then pins — a reviewed,
# attributable act rather than a `kubectl edit`.
#
# It is NOT reconciled by Flux and carries no `${VAR}` tokens, so none of the
# postBuild/envsubst escaping rules in catalog/README.md apply here.
#
# WHAT THE DISPATCHER FILLS IN, and nothing else:
# metadata.name / labels / annotations (name, issue number, branch, mode)
# spec.template.spec.containers[0].image (the digest-pinned agent-runner)
# the `value` of each env entry that HAS a literal value
#
# Every env entry with `valueFrom` is left exactly as committed here, and
# pm-dispatch.build_job REFUSES to overwrite one — so no code path can re-point a
# credential at a different Secret or key.
#
# THREE THINGS RE-CHECK THIS SHAPE, independently:
# 1. `PM_SELFTEST=1 pm-dispatch` (run by infra/agent/build-image.sh) asserts the pinned
# command, the no-args rule, the SA, the token mount, the volume set, the security
# context, and that no env name here is outside the dispatcher's allowlist;
# 2. platform/kyverno/admission/pm-agent-job-shape.yaml re-asserts the same things at the
# API server, fail-closed, for Jobs created by the pm-agent ServiceAccount — because
# Kubernetes places no `use` check on serviceAccountName, so "can create jobs" would
# otherwise be "can run anything";
# 3. platform/kyverno/tests/pm-agent/ exercises (2) with this exact shape as a fixture, so
# a template that drifts OUT of admission fails `make -C ci l0` instead of failing
# against the live API server at 3am.
# =============================================================================
apiVersion: batch/v1
kind: Job
metadata:
# Replaced per assignment by the dispatcher: pm-agent-task-<issue>-<unixtime>.
name: pm-agent-task-template
namespace: pm-agent
labels:
app.kubernetes.io/name: pm-agent-worker
app.kubernetes.io/part-of: pm-agent
spec:
# NO RETRIES. A worker's side effects are a pushed branch, a pull request and an issue
# comment; a second attempt would duplicate all three, and the second run would start from
# a tree the first one already changed. A failed assignment is for a human (or the PM's
# next `revise`) to look at, not for the Job controller to repeat.
backoffLimit: 0
# 90 minutes. Long enough for a real change plus a full `./ci/gate.sh`, short enough that
# a wedged session (a hung network call, a model loop) releases its slot the same morning.
activeDeadlineSeconds: 5400
# Keep finished Jobs for 48h so `kubectl logs` still answers "what did it actually do"
# after a night; the Job controller then reaps them, so the namespace does not accumulate.
ttlSecondsAfterFinished: 172800
template:
metadata:
labels:
app.kubernetes.io/name: pm-agent-worker
app.kubernetes.io/part-of: pm-agent
spec:
restartPolicy: Never
# The worker's identity has NO Kubernetes API rights at all (platform/pm-agent/rbac.yaml)
# and its token is not even mounted. A worker runs model-authored code against a git
# repository; it has no business holding a cluster credential, and the cheapest way to
# 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
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: worker
# Replaced by the dispatcher with the digest-pinned image. A tag here would be
# DENIED by admission; the dispatcher refuses a non-digest PM_WORKER_IMAGE at
# startup so the error names the real cause.
#
# DELIBERATELY ALL-ZERO, AND DELIBERATELY NOT TRACKED BY RENOVATE. This file is
# baked INTO agent-runner, so it cannot carry agent-runner's own digest: bumping it
# would change the image's inputs, CI would publish a new digest, Renovate would bump
# it again — forever. pm-dispatch overwrites this field from PM_WORKER_IMAGE
# (platform/pm-agent/configmap.yaml, which IS tracked) on every Job it creates.
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
imagePullPolicy: IfNotPresent
# The pinned entrypoint, with NO args. Args on a pinned command are
# caller-supplied code; pm-worker takes none and reads its whole input from the
# environment below, which it validates itself.
command:
- /usr/local/bin/pm-worker
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
privileged: false
capabilities:
drop:
- ALL
env:
# --- the assignment (literals, filled per Job by the dispatcher) -----------
- name: PM_TASK_MODE
value: implement
- name: PM_ISSUE_NUMBER
value: "0"
- name: PM_ISSUE_TITLE
value: ""
- name: PM_BRANCH
value: feat/placeholder
- name: PM_BASE_BRANCH
value: main
- name: PM_BRIEF
value: ""
- name: PM_GITEA_URL
value: http://gitea.gitea.svc.cluster.local:3000
- name: PM_GITEA_OWNER
value: Home-Network
- name: PM_GITEA_REPO
value: home-network-k3s-impl
- name: PM_GITEA_USER
value: ""
- name: PM_MODEL
value: claude-opus-5
- name: PM_MAX_TURNS
value: "80"
- name: PM_MAX_BUDGET_USD
value: "10"
# Whether a change the gate REFUSED is still pushed as a red pull request.
# Default false: the issue gets the gate log instead and nothing reaches the
# branch list. Forwarded by the dispatcher from pm-agent-config so the operator
# can flip it without rebuilding this image.
- name: PM_PUSH_ON_RED_GATE
value: "false"
# --- the two credentials (by NAME only, never a value; R-IDP-04) ----------
# Referenced from the same Secret the dispatcher reads. `optional` is absent on
# purpose: a worker that started without a credential would run, fail at the
# first push, and comment something confusing on the issue. Refusing to start is
# the clearer failure.
- 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:
# A node session plus the pinned gate toolchain. The limit is what bounds the
# blast radius of a worker on a 3-node home cluster where RAM is the binding
# constraint: at PM_MAX_ACTIVE_WORKERS=2 the worst case is 2 x 2Gi.
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
# The ONLY two writable paths. The root filesystem is read-only, and the
# admission policy admits no third volume — an extra volume is how a constrained
# pod reaches a host path, a Secret, or another SA's token.
- name: workspace
mountPath: /workspace
- name: tmp
mountPath: /tmp
volumes:
# HOME, the clone, the Claude Code config dir and every cache live here. emptyDir,
# so an assignment starts from a clean tree every time and nothing survives it: the
# branch and the pull request are the output, and they are in Gitea.
- name: workspace
emptyDir:
sizeLimit: 4Gi
# /tmp separately, because not every tool in the pinned toolchain honours $TMPDIR,
# and a read-only root filesystem turns that into a confusing mid-run failure.
- name: tmp
emptyDir:
sizeLimit: 1Gi