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]>
190 lines
9.4 KiB
YAML
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
|