Compare commits

..
Author SHA1 Message Date
jakman85andClaude Opus 5.5 b548224db8 feat(gitea-actions): act_runner capacity 2, dind sized for two concurrent jobs
gate / gate (pull_request) Successful in 2m59s
gate / conformance (pull_request) Skipped
The in-cluster runner ran at the default capacity 1 with no config file, so
gate / images / renovate runs on different refs queued behind each other
(29-759s waits; run 472 waited 759s behind a renovate burst).

- ConfigMap act-runner-config with ONLY runner.capacity: 2, selected via
  CONFIG_FILE (honoured by the v0.6.1 image's run.sh). v0.6.1 LoadDefault()
  applies the same defaults to a partial file as to no file; the
  generate-config example is deliberately not used (its labels would override
  the registered ones, force_pull differs from the default).
- Pod-template config-revision annotation so ConfigMap-only edits can roll it.
- dind: cpu request 100m -> 2 (no limit), memory 256Mi/2Gi -> 1Gi/4Gi.
  Measured at capacity 1: peak 0.69 core, 447Mi working set (30d), 0 OOMs.
- runner: memory 128Mi/512Mi -> 256Mi/1Gi (peak 314Mi at capacity 1).

apps/wave-1/gitea-actions.yaml is a hand-owned EXTRA_UNITS file (the
renderer only places it in a wave), so it is the source, not generated output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:09:08 -06:00
9 changed files with 245 additions and 222 deletions

No files matched your search

+1 -2
View File
@@ -165,8 +165,7 @@ Cost: the job shares the single act_runner slot with the gate, but the build its
merge-gate.yaml the pipeline (lint → l0 → l1 → conformance → decision)
images.yaml build/verify/publish this repo's container images (see above)
scripts/
install-tools.sh installs the pinned tools per stage (from ci/versions.env) — used by the
dormant merge-gate.yaml blueprint only; gate.yml runs in the toolbox image
install-tools.sh installs the pinned tools per stage (from ci/versions.env)
decide.sh the R-TEST-03 merge decision over job results
images.sh the image pipeline's driver (what to build, base, push, digest)
README.md this file
+10 -99
View File
@@ -3,10 +3,8 @@
# This is the LIVE gate wired to the in-cluster Gitea Actions runner (ns gitea-actions,
# Docker executor + DinD). It invokes the AGGREGATE merge gate — `./ci/gate.sh`, which is
# exactly what `make -C ci gate` runs, so CI and the devcontainer gate the same way
# (R-TEST-02) — and since 2026-11-08 it runs INSIDE the same image: the job `container:` is the
# CI-built devcontainer toolbox (see "THE JOB IMAGE" below), not a generic runner image that
# downloads the pinned tools on every run. (See further below for why the script, not `make`,
# is invoked here.) It runs every stage in order —
# (R-TEST-02): the runner just installs the pinned tools from ci/versions.env first (and see
# below for why the script, not `make`, is invoked here). It runs every stage in order —
# spec-immutability, lint, Kyverno L0, render==golden, L1 offline render — and then applies the
# R-TEST-03 decision, printing
# "MERGE GATE: PASS" (0) / "BLOCKED" (1) / "UNDETERMINED" (3) / "NOT APPLICABLE" (0). It is the
@@ -19,8 +17,7 @@
# kinds of non-run must be told apart HERE, in the caller, because only the caller knows which
# environment it is:
# * a pinned tool missing or a seam unwired ON THIS RUNNER is a DEFECT — the toolchain is
# baked into the job image and checked against ci/versions.env two steps up.
# GATE_STRICT_SKIPS=true DECLARES that (no env sniffing, no
# installed for us two steps up. GATE_STRICT_SKIPS=true DECLARES that (no env sniffing, no
# "does this look like CI?" heuristic) and makes those skips exit 1: untolerated, job fails.
# * spec-immutability CANNOT be verified here at all: the spec repo is a separate repo and is
# not checked out into this runner (SPEC_REPO_SLUG unwired; blueprint in merge-gate.yaml). It
@@ -44,32 +41,6 @@
# same script the laptop runs; the extra pre-step is a read-only git-diff check and is
# purely additive. The full pipeline blueprint (spec-checkout, per-layer jobs, decision job)
# is retained dormant in merge-gate.yaml.
#
# THE JOB IMAGE (2026-11-08, feat/gate-toolbox-container). The gate job runs in
# gitea.home.keithjackson.dev/home-network/toolbox, pinned BY DIGEST — the image
# .gitea/workflows/images.yaml builds from infra/devcontainer/Containerfile, verifies, and
# publishes. It is the SAME image `infra/devcontainer/dev.sh` runs, so a local gate and the CI
# gate now execute the identical toolchain (R-TEST-02), not two installs that agree by intent.
# Before this, the job ran in ghcr.io/catthehacker/ubuntu:act-22.04 and spent ~11s of every run
# re-downloading yamllint, check-jsonschema, kyverno, promtool and chainsaw
# (.gitea/workflows/scripts/install-tools.sh l0 + l1; measured on runs 494/495).
# * PINNED BY DIGEST, MOVED BY RENOVATE. The `# renovate-digest:` marker above the image line
# is the repo-wide convention (renovate.json5, "This repo's own CI-published images"):
# Renovate follows the `main` tag and opens a digest-bump PR, whose OWN gate run then
# executes on the new image — the bump tests itself. infra/gitea/registry-retention.sh reads
# the same marker, so the pinned digest is never pruned. Never hand-edit it to a tag.
# * PULL AUTH: none needed. The Home-Network org is public and the registry issues an
# anonymous pull token (measured 2026-11-08: anonymous /v2/token -> manifest 200), so the
# runner's DinD pulls it like any public image. If the org is ever made private, add
# `credentials:` here with a read:package token secret — the automatic Actions token has no
# package scope at /v2/token (see images.yaml's header).
# * COLD PULL: ~467 MB compressed (1.54 GB unpacked). The DinD store is an emptyDir, so the
# first job after a runner-pod restart or a digest bump pays one pull; every other run finds
# it cached (forcePull=false), exactly as it already did for the catthehacker image.
# * NO NODE in the toolbox, so `actions/checkout` (a Node action) cannot run here. The checkout
# step is plain git instead — see its comment for how it keeps fetch-depth-0 semantics.
# * install-tools.sh is NOT deleted: merge-gate.yaml (the dormant blueprint) still calls it,
# and conformance/tests/l0-static/alert-rules-unit-tested asserts its promtool wiring.
---
name: gate
@@ -107,76 +78,16 @@ jobs:
# Single required status check: the static merge gate.
gate:
runs-on: ubuntu-latest
# The CI-built toolbox — the same image dev.sh runs. See "THE JOB IMAGE" in the header.
container:
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81
steps:
# Plain git, because actions/checkout is a Node action and the toolbox has no Node. It
# reproduces what actions/checkout@v7 with `fetch-depth: 0` did here (read from its logged
# commands on runs 490/495): full history of every branch + tags, so
# ci/touched-requirements.sh can diff against origin/<base>; the exact event commit
# checked out (detached for a PR, as `-B <branch>` for a push). One difference, on
# purpose: the job token is passed to the single fetch through GIT_CONFIG_* environment
# variables and is NOT persisted into .git/config — nothing after this step needs
# authenticated git (ci/ and conformance/ make no network git calls), so there is no
# reason to leave a credential in the workspace for the gate's scripts to read.
- name: Checkout (git, full history)
env:
GIT_TOKEN: ${{ github.token }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
# act runs steps with umask 0000, so without this every checked-out directory is
# world-writable — and Ansible then IGNORES infra/ansible/ansible.cfg as a source
# ("Ansible is being run in a world writable directory"), which made the L0 check
# prod-baremetal-not-dev-surface fail on this PR's first CI run (run 500). The
# toolbox has Ansible, so that check now drives it for real. 0022 = what a
# workstation clone gets (reproduced locally: 0000 fails the check, 0022 passes).
umask 0022
git config --global --add safe.directory "$PWD"
git init -q .
git remote add origin "${SERVER_URL}/${REPOSITORY}"
auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \
git -c protocol.version=2 fetch --quiet --no-tags --prune --no-recurse-submodules origin \
'+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' \
"+${SHA}:refs/remotes/checkout/head"
unset auth GIT_TOKEN
case "$REF" in
refs/heads/*) git checkout -q --force -B "${REF#refs/heads/}" "$SHA" ;;
*) git checkout -q --force --detach "$SHA" ;;
esac
[ "$(git rev-parse HEAD)" = "$SHA" ] || { echo "::error::HEAD is not ${SHA}" >&2; exit 1; }
echo "checked out $(git log -1 --format='%H %s') (ref ${REF}); $(git rev-list --count HEAD) commits of history"
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # full history so ci/touched-requirements.sh can diff vs main
# The image is the toolchain, so prove it is the PINNED one before trusting it: every
# *_VERSION in ci/versions.env must equal what the image recorded at build time
# (/etc/dev-toolchain-versions, written by infra/devcontainer/Containerfile; its `verify`
# stage already asserted the version-sensitive binaries report those pins). This replaces
# the per-run install: a drifted toolbox fails HERE, naming the tool, instead of gating with
# a different engine. ci/versions.env and infra/devcontainer/versions.env must move together.
- name: Toolchain is the one ci/versions.env pins
- name: Install pinned CI toolchain (ci/versions.env)
run: |
set -euo pipefail
. ci/versions.env
drift=0
for var in $(grep -oE '^[A-Z_]+_VERSION' ci/versions.env); do
tool="$(printf '%s' "${var%_VERSION}" | tr 'A-Z_' 'a-z-')"
want="${!var}"
got="$(sed -n "s/^${tool}=//p" /etc/dev-toolchain-versions)"
if [ "$got" = "$want" ]; then
printf 'ok %-18s %s\n' "$tool" "$want"
else
printf '::error::DRIFT %s: ci/versions.env pins %s, the job image has %s\n' "$tool" "$want" "${got:-nothing}"
drift=1
fi
done
[ "$drift" -eq 0 ]
./.gitea/workflows/scripts/install-tools.sh l0
./.gitea/workflows/scripts/install-tools.sh l1
# R-TEST-06: the spec is read-only to agents. An EXPLICIT early surfacing of the
# spec-immutability check, so its result is a distinct, tolerant status line in the
+69 -5
View File
@@ -77,6 +77,42 @@ spec:
requests:
storage: 1Gi
---
# act_runner config file — DELTAS ONLY. act_runner v0.6.1 LoadDefault()
# (internal/pkg/config/config.go) unmarshals this file onto a ZERO Config and then fills every
# unset field with the same default it uses when there is no file at all, so a file holding
# only `runner.capacity` changes exactly that and nothing else. Do NOT paste in the output of
# `act_runner generate-config`: that example is NOT the defaults. Its `runner.labels` would
# OVERRIDE the registered labels in /data/.runner (daemon.go: config labels win when non-empty)
# and repoint ubuntu-latest at a different job image; its `force_pull: true` differs from the
# no-file default (false); and its sample `envs` would be injected into every job.
#
# Using a file also makes act_runner IGNORE the legacy GITEA_RUNNER_CAPACITY / _FILE /
# _ENVIRON / _ENV_FILE / GITEA_DEBUG / GITEA_TRACE env vars (deprecated.go). None of them is
# set on this Deployment; the GITEA_INSTANCE_URL / _REGISTRATION_TOKEN / _NAME / _LABELS env
# below is consumed by the image's run.sh for registration, not by that legacy path.
#
# A ConfigMap edit alone does NOT restart the runner (it reads the file once at start). If a
# future change touches ONLY this ConfigMap, also bump the Deployment's pod template (e.g. the
# config-revision annotation below) or the new value stays inert until the next restart.
apiVersion: v1
kind: ConfigMap
metadata:
name: act-runner-config
namespace: gitea-actions
labels:
app.kubernetes.io/name: act-runner
data:
config.yaml: |
runner:
# Two concurrent jobs (default 1). Measured 2026-11-08: queue wait ~1s normally, but
# 29-759s whenever gate / images / renovate runs collide (run 472 waited 759s behind a
# renovate burst). Both jobs share the ONE dind daemon below; act_runner names each job's
# containers GITEA-ACTIONS-TASK-<task id> and creates a network per job, so they do not
# collide. Workflow `concurrency:` groups are enforced by Gitea server-side and are
# unaffected: gate.yml's gate-<ref> group still cancels a superseded run on the SAME ref,
# and renovate's group still serialises renovate. The dind resources are sized for TWO jobs.
capacity: 2
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -95,6 +131,10 @@ spec:
metadata:
labels:
app.kubernetes.io/name: act-runner
annotations:
# Bump when act-runner-config changes without any other pod-template change, so the
# Deployment rolls and act_runner re-reads its config (see the ConfigMap header).
homelab.keithjackson.dev/act-runner-config-revision: "1" # capacity 2
spec:
# CI holds no cluster credentials — do not mount a SA token into the runner or
# (via the shared netns) the job containers (R-SEC-02, docs/14).
@@ -146,12 +186,23 @@ spec:
mountPath: /certs
- name: dind-storage
mountPath: /var/lib/docker
# Sized for runner.capacity: 2. Every job's containers run UNDER this daemon, so this
# container's cgroup carries the whole CI workload. Measured 2026-11-08 (Prometheus,
# capacity 1): peak CPU 0.69 core (1m rate, 30d), peak working set 447Mi (30d), 0 OOMs.
# cpu request 2: two jobs at today's peak is ~1.4 core, and the gate is being
# parallelised, so per-job demand rises. The request is what the CFS share is
# weighted by under contention (100m was a 1/20 share); NO cpu limit, deliberately,
# so an idle node's spare cores are still usable. k3s-prod-2 (8 CPU) had 2945m
# requested; +1.9 core = ~4.85 (61%), and prod-1/prod-3 fit too if it reschedules.
# memory request 1Gi / limit 4Gi: two jobs at today's peak is ~0.9Gi. An OOM kill here
# takes down dockerd and with it BOTH running jobs, so the limit carries ~4x headroom
# over that for the parallelised gate. Nodes have ~31Gi allocatable.
resources:
requests:
cpu: 100m
memory: 256Mi
cpu: "2"
memory: 1Gi
limits:
memory: 2Gi
memory: 4Gi
startupProbe:
exec:
command:
@@ -195,23 +246,36 @@ spec:
value: /certs/client
- name: DOCKER_TLS_VERIFY
value: "1"
# The image's run.sh passes `--config <this>` to BOTH `act_runner register` and
# `act_runner daemon` when CONFIG_FILE is set (act_runner v0.6.1 scripts/run.sh).
- name: CONFIG_FILE
value: /etc/act_runner/config.yaml
volumeMounts:
- name: data
mountPath: /data
- name: certs
mountPath: /certs
readOnly: true
- name: config
mountPath: /etc/act_runner
readOnly: true
# Measured 2026-11-08 at capacity 1: peak CPU 0.24 core, peak working set 314Mi (30d)
# against the old 512Mi limit. It holds per-task state, log buffers and the embedded
# actions/cache server for every concurrent task, so the limit is doubled for capacity 2.
resources:
requests:
cpu: 100m
memory: 128Mi
memory: 256Mi
limits:
memory: 512Mi
memory: 1Gi
volumes:
- name: data
persistentVolumeClaim:
claimName: act-runner-data
- name: certs
emptyDir: {}
- name: config
configMap:
name: act-runner-config
- name: dind-storage
emptyDir: {}
+1 -1
View File
@@ -99,7 +99,7 @@ caller that must distinguish them invokes `./ci/gate.sh` directly.
| stage | a skip there means | verdict |
|---|---|---|
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is the job image — the CI-built toolbox, `.gitea/workflows/gate.yml` `container:` — so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is installed for us by `.gitea/workflows/scripts/install-tools.sh`, so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
| `spec-immutability` (R-TEST-06) | the sibling spec repo is not checked out, so the spec **could not be verified** | **UNDETERMINED** — unverified is unverified. Deliberately *not* upgraded to BLOCKED by strict skips: the live runner does not check the spec repo out at all, so blocking would leave the only enforced status check permanently red for a reason no PR could clear. It is therefore the **one** skip whose non-answer a caller may knowingly tolerate, and `.gitea/workflows/gate.yml` does so in one visible line. `REQUIRE_SPEC_VERIFICATION=true` makes it a hard block once that checkout is wired. Locally: check the spec repo out beside this one, or set `SPEC_REPO` (`dev.sh` mounts a sibling automatically). |
| `decisions-log` | **nothing** — this stage has no skip path. It reads only files in the tree under test and needs no tool and no cluster, so there is no environment in which it "could not run" | a reported skip is a **defect in the stage** and BLOCKS. The stage fails when the frozen `DECISIONS-LOG.md` is modified (which silently re-creates, for every *other* open branch, the tail-hunk conflict that made four of five PRs unmergeable on 2026-09-29) or when a `decisions/` entry is malformed. There is deliberately **no** `REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard. See `decisions/README.md`. |
| `conformance` (L2/L3) | `CLUSTER_AVAILABLE!=true` — the environment **declared** the capability absent (documented Phase-0 behaviour) | **does not deny a PASS**: a declared-inapplicable stage was never *supposed* to run. It is NAMED in the PASS headline and step summary, the touched requirements it would have covered are reported UNVERIFIED, and `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) turns that into a block. A skip **despite** `CLUSTER_AVAILABLE=true` is a defect and BLOCKS. |
+3 -3
View File
@@ -46,8 +46,8 @@
# WHICH SKIPS BLOCK, AND WHY (per stage — a skip is not one thing):
#
# lint / l0 / render-golden / l1 A skip here means a PINNED tool is absent or a wired
# seam went unwired. On a CI runner the pinned toolchain is the job image itself (the
# CI-built toolbox, .gitea/workflows/gate.yml), so a skip there is a DEFECT, not an
# seam went unwired. On a CI runner the pinned toolchain is installed for us by
# .gitea/workflows/scripts/install-tools.sh, so a skip there is a DEFECT, not an
# environment: it BLOCKS. On a workstation a missing tool is a real local condition, so
# it degrades to UNDETERMINED (still non-zero, still never a pass). See strict_skips()
# in ci/lib.sh for how CI is detected and why misdetecting it cannot fail open.
@@ -257,7 +257,7 @@ for s in lint l0 render-golden l1; do
skip)
if strict_skips; then
err "${s}: skipped (unwired seam / missing tool) under STRICT skips, where the pinned"
err " toolchain is baked into the job image (.gitea/workflows/gate.yml container:) and every"
err " toolchain is installed for us (.gitea/workflows/scripts/install-tools.sh) and every"
err " seam is on main — so this is a DEFECT, not an environment -> blocks merge"
summary "- ${s}: **SKIPPED under strict skips** (unwired seam / missing tool) — blocks merge"
BLOCK=1
+3 -3
View File
@@ -97,9 +97,9 @@ cluster_available() { truthy "${CLUSTER_AVAILABLE:-false}"; }
# --- Skip strictness: is this a CI runner, or a workstation? -------------------
# A SKIPPED stage is never green anywhere (HARD RULE 4), but its SEVERITY differs:
#
# CI the pinned toolchain IS the job image (the CI-built toolbox; gate.yml `container:`),
# and every seam is on `main`. A stage that skips here is a
# DEFECT (a tool dropped from the image, a seam that went unwired) -> BLOCK.
# CI the pinned toolchain is installed by .gitea/workflows/scripts/install-tools.sh
# before the gate runs, and every seam is on `main`. A stage that skips here is a
# DEFECT (a tool dropped from the installer, a seam that went unwired) -> BLOCK.
# local a missing pinned tool is a real, ordinary local condition -> the gate reports
# UNDETERMINED (exit 3): not green, not a pass, but distinguishable from a red.
#
@@ -1,104 +0,0 @@
---
date: 2026-11-08
branch: feat/gate-toolbox-container
title: The merge gate runs inside the CI-built toolbox image, pinned by digest — no per-run tool install
---
## What changed
`.gitea/workflows/gate.yml`, job `gate` (job name, the gate step, its env and its
`|| [ "$?" -eq 3 ]` tolerance are unchanged, so the required context `gate / gate (pull_request)`
is unchanged):
- **`container:`** `gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501e…ef81` — the
image `images.yaml` published from `dd94101` (run 789; it is today's `main` and `sha-dd941016478c`
tag). The same image `infra/devcontainer/dev.sh` runs, so local and CI gate on the identical
toolchain. A `# renovate-digest: image=… tag=main` marker sits on the line above, the repo's
existing convention; `infra/gitea/registry-retention.sh` reads the same marker, so this digest
is protected from pruning.
- **`actions/checkout` replaced by a plain-git step.** The toolbox has no Node (measured:
`command -v node` → missing), and `actions/checkout` is a Node action. Chosen over adding Node to
the toolbox because that would need two sequential PRs (Containerfile, then a publish, then the
pin) and would put a JS runtime into the operator toolbox only to run one action. The step
reproduces what checkout@v7 `fetch-depth: 0` actually ran here (read from runs 490 and 495):
every branch to `refs/remotes/origin/*`, tags, plus the event SHA; detached for a PR,
`-B <branch>` for a push; asserts `HEAD == github.sha`. The job token goes to that one fetch via
`GIT_CONFIG_*` env and is **not** persisted in `.git/config` (nothing later needs network git).
It sets **`umask 0022`** first: the PR's first CI run (run 500) failed L0
`prod-baremetal-not-dev-surface` because act runs steps with umask 0000, every checked-out
directory was world-writable, and Ansible (present in the toolbox, absent from the catthehacker
image) then ignored `infra/ansible/ansible.cfg`. Reproduced locally in the pinned image: clone
under umask 0000 → that check fails with the same message; under 0022 → `check OK`. Side
effect worth knowing: the Ansible-driven parts of that check now actually execute in CI.
- **"Install pinned CI toolchain" step removed**, replaced by **"Toolchain is the one
ci/versions.env pins"**: every `*_VERSION` in `ci/versions.env` must equal the image's
`/etc/dev-toolchain-versions`, else `::error::DRIFT` and the job fails. Without this, dropping
the installer would have made `ci/versions.env` an unenforced claim for everything except
promtool (which `alert-rules-unit-tested` already asserts).
- Header comments rewritten for the new job image (pull auth, cold-pull size, no-Node, why
`install-tools.sh` stays).
`renovate.json5`: the `renovate-digest` custom manager now also scans `.gitea/workflows/gate.yml`;
the `github-actions` manager is disabled for this repo's own images (same reason the `kubernetes`
manager already was: it would see a digest-only image and look up `latest`).
Comment/message wording only: `ci/gate.sh` (header + the strict-skip error text), `ci/lib.sh`,
`ci/README.md`, `.gitea/workflows/README.md` — they said the CI toolchain comes from
`install-tools.sh`.
`.gitea/workflows/scripts/install-tools.sh` is **kept**: `merge-gate.yaml` (dormant blueprint)
calls it, and `conformance/tests/l0-static/alert-rules-unit-tested/check.sh` asserts its promtool
wiring (not touched — another agent owns `conformance/`).
**Consequence to know:** every toolbox digest bump is now a Renovate PR touching `.gitea/**`,
i.e. a sensitive path needing an operator `/approve-override`. That is arguably correct (a new
toolchain changes what green means — and `ci/versions.env` was already sensitive), but it is a
new recurring operator touch.
## What I verified
- Registry pull auth: the Home-Network org is `public`; anonymous `/v2/token` → manifest `200`,
`docker-content-digest: sha256:83a3501e…ef81` for both `main` and `sha-dd941016478c`. So no
`container: credentials:` is needed. (The Actions token could not have been used anyway — no
package scope, per images.yaml's header.)
- Image: 466,912,274 bytes compressed across 6 layers (manifest sum), 1,541,728,125 bytes
unpacked (`podman image inspect`). Local cold pull 10.4s on the workstation.
- Image tool versions equal `ci/versions.env` for all 8 pins (kyverno 1.19.0, kyverno-json 0.0.3,
chainsaw 0.2.12, yamllint 1.35.1, kubectl 1.36.4, yq 4.44.6, promtool 3.14.0, check-jsonschema
0.29.4) — both via `/etc/dev-toolchain-versions` and by running each binary's `--version`.
`sleep`, `tail`, `git` 2.56.0, `bash`, `python3`, `jq`, `curl` present (act's entrypoint is
`/bin/sleep 10800`).
- The new checkout script, run in the pinned image against the public repo (auth header disabled
for the test only): PR ref `refs/pull/143/head` → detached at `60e3a24`, 1018 commits; push ref
`refs/heads/main` → `On branch main` at `e8d2666`; `origin/main` present in both. The
toolchain-drift step passes on the real `ci/versions.env` and exits 1 naming both tools when
yamllint/promtool pins are altered.
- Renovate marker regex (copied from renovate.json5) matches gate.yml exactly once, capturing
depName/tag/digest.
- LOCAL GATE, run **in the pinned CI image itself** (`IMAGE=<the digest above>` through
`dev.sh`, `DEV_KUBECONFIG=/dev/null`, ENGINE shim adding the main checkout's `.git` read-only),
on commit `9537ca2`: **rc 3**; spec-immutability, decisions-log (78 entries), lint, l0,
render-golden, l1, approval-policy (N/A locally — not a PR event) all pass; **gitea-settings
the only stage not run** (no `SETTINGS_API_TOKEN` locally); L2/L3 conformance skipped as
usual. rc 3 is not green.
- **CI, this PR's own runs** (Gitea API `/actions/runs/{id}/jobs` + job logs):
- run 500 (`514ed5d`, cold DinD cache: `Image exists? false`, image pulled): job 151s; set-up
incl. pull **9s**, checkout <1s, toolchain check 1s (all 8 pins `ok`), gate 140s → BLOCKED by
L0 `prod-baremetal-not-dev-surface` (umask, fixed above) + approval-policy.
- run 503 (`27d603f`, warm: `Image exists? true`): job **140s**; set-up 1s, checkout 1s,
toolchain check 0s, gate 137s. Every stage passes — l0 (incl. prod-baremetal-not-dev-surface),
lint, decisions-log, render-golden, l1, **gitea-settings** — except **approval-policy**,
the expected sensitive-path block. spec-immutability skipped (expected, no spec repo in CI).
- baseline before (runs 494/495, push on main): job 180s/179s; set-up 1–2s, checkout 1s,
**install 11s**, gate 164–165s. The install step is gone; the remaining ~25s gate-step delta
is NOT claimed as caused by this change (different base commits, one sample each).
- Re-gated locally on `27d603f` in the pinned image: rc 3, gitea-settings the only not-run stage.
## What I did NOT verify
- Renovate actually opening a digest-bump PR for gate.yml — only the regex was tested, not a
Renovate run.
- Push-to-main behaviour in CI (only the PR event runs before merge).
- Whether the gate-step speedup (165s → 137s) is real or run-to-run variance.
- That the cold-pull cost stays ~9s under a busier node or a bigger digest delta; one sample.
+155
View File
@@ -0,0 +1,155 @@
---
date: 2026-11-08
branch: feat/runner-capacity-2
title: act_runner runs two jobs at once, and dind is sized for two
---
## What changed
`apps/wave-1/gitea-actions.yaml` only. That file is a **hand-owned** non-catalog unit
(`EXTRA_UNITS["gitea-actions"]` in `catalog/render.py`): the renderer only decides which wave
it lands in, it does not generate its bytes, and `ci/render-golden.sh` compares only files the
catalog renders. So it is edited in place, and no re-render was needed or run.
1. **New ConfigMap `act-runner-config`** holding a config file with ONE key,
`runner.capacity: 2`, mounted read-only at `/etc/act_runner/` and selected with
`CONFIG_FILE=/etc/act_runner/config.yaml`. The image's `run.sh` (act_runner v0.6.1) turns
that into `--config` for both `register` and `daemon`.
- Deltas only, on evidence: v0.6.1 `LoadDefault()` unmarshals the file onto a zero Config and
then applies the same defaults as the no-file path, so the other settings are unchanged.
- The `generate-config` example was deliberately NOT used. Its `runner.labels` would override
the registered labels in `/data/.runner` and repoint `ubuntu-latest` at
`docker.gitea.com/runner-images`. Its `force_pull: true` is not the default. Its sample `envs`
would be injected into every job.
- With a file present, act_runner ignores the legacy `GITEA_RUNNER_CAPACITY` /
`_FILE` / `_ENVIRON` / `_ENV_FILE` / `GITEA_DEBUG` / `GITEA_TRACE` env vars. None of
them is set on this Deployment.
2. **Pod-template annotation `homelab.keithjackson.dev/act-runner-config-revision: "1"`.**
act_runner reads its config once, at start, so a change to the ConfigMap alone would never
take effect. Bump this annotation whenever you change only the ConfigMap.
3. **dind resources:** requests `cpu 100m -> 2` and `memory 256Mi -> 1Gi`; limit
`memory 2Gi -> 4Gi`. There is still no CPU limit, on purpose.
4. **runner resources:** request `memory 128Mi -> 256Mi`, limit `memory 512Mi -> 1Gi`. CPU stays
at `100m`.
Why these sizes. Measured 2026-11-08 through Prometheus, at capacity 1:
| container | peak CPU (1m rate, 30d) | peak working set (30d) | OOM events (7d) |
|---|---|---|---|
| dind | 0.69 core | 447 MiB | 0 |
| runner | 0.24 core | 314 MiB | 0 |
At the moment of measurement the pod was idle (`top`: dind 1m/138Mi, runner 1m/83Mi), on
k3s-prod-2.
- **CPU request 2.** Two jobs at today's peak is about 1.4 cores. The gate is being parallelised
in other work, so each job will use more than that. The request sets dind's CFS weight under
contention; at 100m it got a 1/20 share. With no limit, dind can still use spare cores on an
idle node.
- **Memory 1Gi / 4Gi.** Two jobs at today's peak is about 0.9 GiB. If dind is OOM-killed,
dockerd goes down and takes both running jobs with it, so the limit is about 4x that.
- **Runner limit 512Mi -> 1Gi.** Its 30-day peak was already 61% of the old limit, and with
capacity 2 it holds state for two tasks.
Scheduling headroom: CPU requests per node were measured with `describe node`. The pod's total
CPU request goes from 200m to 2100m (+1.9 cores).
| node | CPU requested now | after the change | allocatable |
|---|---|---|---|
| prod-2 (current) | 2945m | ~4845m (61%) | 8 |
| prod-1 | 3175m | ~5075m | 8 |
| prod-3 | 3840m | ~5740m (72%) | 8 |
The pod fits on any of the three if the RWO PVC moves it.
Concurrency semantics. Workflow `concurrency:` groups are enforced by Gitea on the server, not by
the runner, so capacity does not change them:
- `gate.yml` `gate-${{ github.ref }}` with cancel-in-progress still cancels a superseded run on
the same ref.
- `renovate` (group `renovate`, no cancel) and `images-${{ github.ref }}` still serialise
themselves.
What capacity 2 does change: runs on *different* refs (a PR gate next to a renovate or images
run, or two PRs) now run side by side instead of queueing. The required check
`gate / gate (pull_request)` is still one job per PR head, and gate.yml's other job
(`conformance`) is `if: false`, so it never runs.
Both jobs share one dockerd. act_runner gives each task its own container prefix
(`GITEA-ACTIONS-TASK-<task id>`) and its own network, and no workflow uses fixed container names
or host ports.
## What I verified
- **Source of truth.** `catalog/render.py` `EXTRA_UNITS` places `gitea-actions.yaml` by
anchor/offset only. `ci/render-golden.sh` iterates over rendered files only, so this file is
outside render==golden. No `--emit-waves` was run.
- **act_runner v0.6.1 behaviour, read in its source** (tag v0.6.1):
- `config.LoadDefault()` treats a partial file as described above.
- `daemon.go`: config labels override `.runner` labels only when non-empty.
- `poller.go`: a semaphore of `cfg.Runner.Capacity` bounds concurrent tasks.
- `runner.go`: per-task container name prefix.
- The image's `run.sh`, read from `gitea/act_runner:0.6.1`, honours `CONFIG_FILE`.
- **The live pod has no config file today.** `/data` holds only `.runner` and `lost+found`.
`act_runner --version` reports v0.6.1. No LimitRange or ResourceQuota exists in
`gitea-actions`.
- **Resource numbers.** All figures above come from read-only `kubectl top`, `describe node` and
Prometheus queries against k3s-prod.
- **dind disk.** The emptyDir sits on the node's 11.8G ephemeral filesystem: 4.0G used, 7.2G
free. Image cache is 2.18GB, which is the one `catthehacker/ubuntu:act-22.04` image. Two
concurrent jobs share that image.
- **Embedded config.** It parses as YAML to `{'runner': {'capacity': 2}}`. The only `$` in the
manifest is the existing `${BASE_DOMAIN}`, so nothing new is exposed to Flux postBuild
envsubst.
- **Approval policy.** `apps/wave-1/` matches no `sensitive_paths` glob in
`infra/gitea/approval-policy.json`, so this PR merges on a green gate alone.
- **Local gate.** Pinned toolbox, run from the worktree on the committed tree. The rc and skipped
stages are given in the PR description.
## What I did NOT verify
- **UNVERIFIED: that the runner actually starts with the config and runs two jobs at once.**
Nothing is applied until Flux reconciles `main`.
- **Capacity cannot be seen through Gitea's API.** act_runner v0.6.1 does not report capacity to
Gitea: `Declare` sends only version and labels. `GET /api/v1/admin/actions/runners` therefore
cannot show "capacity 2". Concurrency has to be observed instead.
- **UNVERIFIED: whether the parallelised gate fits in these sizes.** That gate has not landed.
Re-measure after it does, with
`max_over_time(sum by (container)(rate(container_cpu_usage_seconds_total{namespace="gitea-actions"}[1m]))[7d:30s])`.
- **Rollout cancels in-flight jobs.** The rollout restarts the runner pod (strategy Recreate,
`shutdown_timeout: 0s`), and any job running at that moment is cancelled, possibly including
the `push`-to-main gate run triggered by this very merge. Re-run it if that happens.
- **dind disk is not bounded by a limit.** With two jobs it holds two jobs' workspaces and layers
on the same 11.8G node filesystem. I did not add an `ephemeral-storage` limit, because an
eviction would kill both jobs. Watch kubelet DiskPressure on the runner's node.
- **Stale comments in sensitive paths.** `.gitea/workflows/gate.yml` (line ~52) and
`.gitea/workflows/README.md` (line ~86) still say "`act_runner` runs at capacity 1". I left
them alone because `.gitea/**` is a sensitive path and editing them would put this PR behind
human approval. Fix them in a separate PR.
### Operator post-merge checklist
1. Flux applied it:
`kubectl --context k3s-prod -n flux-system get kustomization apps-wave-1 -o jsonpath='{.status.lastAppliedRevision}'`
equals the merge commit.
2. The pod restarted with the config:
- `kubectl --context k3s-prod -n gitea-actions get pod -l app.kubernetes.io/name=act-runner`
shows a new pod, 2/2 Running.
- `kubectl ... exec deploy/act-runner -c runner -- cat /etc/act_runner/config.yaml` shows
`capacity: 2`.
- `kubectl ... exec deploy/act-runner -c runner -- ps` shows `act_runner daemon --config /etc/act_runner/config.yaml`.
- The logs show "declare successfully" with the two expected labels (`ubuntu-latest`,
`homelab` -> `catthehacker/ubuntu:act-22.04`). That confirms the labels did not change.
3. The runner is registered and online:
- `GET /api/v1/admin/actions/runners` lists `incluster-dind`, online, with the same ID as
before (no duplicate row).
- Capacity is NOT shown there; see above.
4. Concurrency is real: trigger two runs on different refs (e.g. two PR gates, or a PR gate plus a
`workflow_dispatch` of renovate). Then either:
- in `GET /api/v1/repos/Home-Network/home-network-k3s-impl/actions/tasks`, confirm their
`run_started_at` .. `updated_at` windows overlap; or
- check
`docker --host=tcp://127.0.0.1:2376 --tlsverify ... ps` in the dind container lists two
`GITEA-ACTIONS-TASK-*` container sets at once.
5. Resources: `kubectl --context k3s-prod -n gitea-actions describe pod` shows dind requests
cpu 2 / memory 1Gi, limit memory 4Gi, and runner limit memory 1Gi.
+3 -5
View File
@@ -142,9 +142,7 @@
"description": "This repo's own CI-published images, pinned by digest, following the tag named in a `# renovate-digest:` marker on the previous line.",
"managerFilePatterns": [
"/(^|/)versions\\.env$/",
"/(^|/)platform/pm-agent/.+\\.ya?ml$/",
// ADDED 2026-11-08: the merge gate's job `container:` is the toolbox, by digest.
"/^\\.gitea/workflows/gate\\.ya?ml$/"
"/(^|/)platform/pm-agent/.+\\.ya?ml$/"
],
"matchStrings": [
"#\\s*renovate-digest:\\s*image=(?<depName>\\S+)\\s+tag=(?<currentValue>\\S+)\\s*\\n[^\\n]*?(?<currentDigest>sha256:[a-f0-9]{64})"
@@ -292,8 +290,8 @@
"enabled": false
},
{
"description": "ADDED 2026-10-29. This repo's own images are followed ONLY through the `# renovate-digest:` markers above. The `kubernetes` manager also sees platform/pm-agent/deployment.yaml and infra/agent/worker-job-template.yaml, where the image has a digest and no tag; left on, it would look those up as `latest` (a tag CI never pushes) and, for the template, re-open the self-referential loop the custom manager deliberately avoids. `github-actions` ADDED 2026-11-08 for the same reason: it extracts the gate job's digest-only `container: image:` in .gitea/workflows/gate.yml, which the custom manager already follows through its marker.",
"matchManagers": ["kubernetes", "github-actions"],
"description": "ADDED 2026-10-29. This repo's own images are followed ONLY through the `# renovate-digest:` markers above. The `kubernetes` manager also sees platform/pm-agent/deployment.yaml and infra/agent/worker-job-template.yaml, where the image has a digest and no tag; left on, it would look those up as `latest` (a tag CI never pushes) and, for the template, re-open the self-referential loop the custom manager deliberately avoids.",
"matchManagers": ["kubernetes"],
"matchPackageNames": [
"gitea.home.keithjackson.dev/home-network/toolbox",
"gitea.home.keithjackson.dev/home-network/agent-runner",