Files
home-network-k3s-impl/infra/agent/worker-job-template.yaml
T
jakman85andClaude Opus 5.5 e539109830 feat(images): build, verify and publish the three images in CI; Renovate carries the digests
Operator rule 2026-10-29: image creation, hosting and lifecycle belong to CI/CD, never to a
workstation build/push. Nothing had ever been pushed to the Gitea registry.

- .gitea/workflows/images.yaml + scripts/images.sh: PRs build+verify (no credential); main
  builds, verifies and pushes toolbox, agent-runner (FROM toolbox) and conformance-runner as
  sha-<commit> + main, reading the digest back from the registry. Only changed images build.
- Each image's own build-image.sh does the build and in-image verification, now engine-neutral
  (DinD: no podman-only calls, probe over stdin not a bind mount). New toolbox build-image.sh.
- Digest references carry `# renovate-digest:` markers; renovate.json5 gains a custom.regex
  manager for them. The worker Job template is deliberately untracked (it would loop).
- renovate.yaml pins renovate-version 44.167.0: the action's default (39.264.0) rejects the
  current config's managerFilePatterns, so every run would have died at CONFIG_VALIDATION.
- Push credential: REGISTRY_PUSH_TOKEN secret; the Actions token has no package scope in
  Gitea 1.24.3 (container.go Authenticate). Retention: infra/gitea/registry-retention.sh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:59:55 -05:00

183 lines
8.9 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
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