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]>
183 lines
8.9 KiB
YAML
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
|