--- # ============================================================================= # 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--. 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