Files
home-network-k3s-impl/apps/wave-1/gitea-actions.yaml
T
jakman85andClaude Opus 5.5 b548224db8
gate / gate (pull_request) Successful in 2m59s
gate / conformance (pull_request) Skipped
feat(gitea-actions): act_runner capacity 2, dind sized for two concurrent jobs
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

282 lines
13 KiB
YAML

---
# Gitea Actions runner (act_runner) — Phase 6 CI/CD (WS-GITOSS).
#
# WHY A DEDICATED, NON-APP NAMESPACE:
# The runner's job executor is a Docker-in-Docker (DinD) sidecar that MUST run
# `privileged: true` (dockerd needs it). The Kyverno `restrict-host-namespaces`
# policy denies privileged containers, but it (and the egress-class / auth-mode /
# default-deny-baseline policies) scope ONLY to namespaces labeled
# homelab.keithjackson.dev/app-namespace: "true". This namespace deliberately does
# NOT carry that label — it is CI plumbing, not a user-facing app — so the privileged
# DinD sidecar is admitted WITHOUT a policy exception. (Verified: a privileged Pod in
# a non-app namespace passes server-side admission; Kyverno test fixture
# `platform-workload` is likewise Excluded from restrict-host-namespaces.)
#
# EXECUTOR MODEL — Docker executor + DinD sidecar (privileged):
# act_runner runs each job step inside a container it launches via a Docker daemon.
# That daemon is the co-located `dind` sidecar (shared pod netns → tcp://localhost:2376,
# TLS via a shared emptyDir of certs). Tradeoff vs the host/self-hosted executor: DinD
# needs `privileged`, but it gives clean per-job isolation and lets the workflow pick its
# own job image (the pinned toolchain), which the host executor cannot. Privileged is
# contained to this CI-only namespace and the runner mounts NO ServiceAccount token
# (automountServiceAccountToken: false) — CI holds no cluster credentials (R-SEC-02).
apiVersion: v1
kind: Namespace
metadata:
name: gitea-actions
labels:
# NOTE: intentionally NO homelab.keithjackson.dev/app-namespace label (see header).
homelab.keithjackson.dev/ci: act-runner
---
# Default-deny ingress; broad egress (a CI runner legitimately needs the internet:
# ghcr.io / github.com / docker hub / dl.k8s.io / pypi for job images + pinned tools,
# and the Gitea ingress VIP for registration + repo clone). Nothing connects INTO the
# runner, so ingress is denied entirely.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress-allow-egress
namespace: gitea-actions
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
# DNS (cluster + split-horizon resolution of gitea.home.keithjackson.dev).
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Everything else the runner + DinD need: Gitea VIP + internet registries/tool hosts.
- {}
---
# Runner state (config.yaml + the `.runner` registration file) persists here so the
# runner registers exactly ONCE and reuses its identity across restarts (no duplicate
# offline rows in Gitea). RWO → Deployment strategy must be Recreate (fix/recreate-strategy-rwo).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: act-runner-data
namespace: gitea-actions
labels:
app.kubernetes.io/name: act-runner
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
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:
name: act-runner
namespace: gitea-actions
labels:
app.kubernetes.io/name: act-runner
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: act-runner
template:
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).
automountServiceAccountToken: false
# ---- Docker-in-Docker: the job executor daemon (privileged) -------------------
#
# NATIVE SIDECAR (k3s v1.36; sidecar containers are GA). It is an initContainer with
# `restartPolicy: Always`, which means: it starts BEFORE the regular containers, keeps
# running alongside them, and is terminated after them. kubelet holds `runner` until
# this container's startupProbe passes, so the ordering is enforced by the kubelet
# instead of hand-rolled shell in the runner.
#
# This REPLACES two bounded wait-loops that used to live in the runner's `command:`.
# Deleting them also deletes an envsubst hazard: clusters/base/apps-wave-1.yaml runs
# postBuild substitution over this ENTIRE manifest, comments included, and those loops
# depended on bare dollar-names surviving it untouched. There is now no shell here at
# all, so there is nothing to escape or mis-escape.
#
# WHY THE PROBE IS `exec docker ... info` AND NOT `tcpSocket`. A tcpSocket probe would
# re-open the exact gap the old Stage 1 existed to close: dockerd writes the TLS certs
# and only THEN begins listening, so the port can accept a connection while the certs
# are absent or incomplete -- and act_runner hard-exits on a missing
# /certs/client/ca.pem. A full TLS-verified `docker info` proves BOTH that the certs
# are usable and that the daemon answers.
#
# CORRECTION (measured 2026-09-29, after this conversion shipped): the commit that
# introduced this claimed the conversion would REMOVE the one-per-start dockerd line
# `http: TLS handshake error from 127.0.0.1: EOF`. IT DOES NOT. Exactly one still
# appears, immediately after "API listen on [::]:2376" -- it is now this probe's FIRST
# attempt, landing microseconds after dockerd begins listening and cut off by
# timeoutSeconds: 1. The next poll succeeds. It is cosmetic, exactly as cosmetic as it
# was before, and deliberately NOT chased: raising timeoutSeconds to hide one benign
# startup line would slow every subsequent poll. Left documented rather than silenced.
#
# periodSeconds 1 x failureThreshold 60 preserves the previous 60s cap.
initContainers:
- name: dind
restartPolicy: Always # <- this field is what makes it a SIDECAR
image: docker:29-dind
securityContext:
privileged: true
env:
# Generate + publish TLS certs into the shared emptyDir; dockerd then listens
# on tcp://0.0.0.0:2376 (TLS) which the runner reaches at localhost:2376.
- name: DOCKER_TLS_CERTDIR
value: /certs
volumeMounts:
- name: certs
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: "2"
memory: 1Gi
limits:
memory: 4Gi
startupProbe:
exec:
command:
- docker
- --host=tcp://127.0.0.1:2376
- --tlsverify
- --tlscacert=/certs/client/ca.pem
- --tlscert=/certs/client/cert.pem
- --tlskey=/certs/client/key.pem
- info
periodSeconds: 1
failureThreshold: 60
containers:
# ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ----
- name: runner
image: gitea/act_runner:0.6.1
# No `command:` override. The image's own entrypoint (/sbin/tini -- /opt/act/run.sh)
# registers from env on first boot and runs `act_runner daemon`. The two wait-loops
# that used to live here are gone: kubelet now holds this container until the dind
# sidecar's startupProbe passes (see initContainers above).
env:
- name: GITEA_INSTANCE_URL
value: https://gitea.${BASE_DOMAIN}
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
name: gitea-actions-runner
key: token
- name: GITEA_RUNNER_NAME
value: incluster-dind
# runs-on label -> job image. Both map to the catthehacker "act" image, which
# carries bash/git/curl/tar/python3+pip/node so .gitea/workflows scripts can
# install the pinned CI toolchain (ci/versions.env) and actions/checkout runs.
- name: GITEA_RUNNER_LABELS
value: "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,homelab:docker://ghcr.io/catthehacker/ubuntu:act-22.04"
# Reach the DinD daemon over the shared pod network namespace, TLS-verified
# against the certs the sidecar drops into the shared /certs emptyDir.
- name: DOCKER_HOST
value: tcp://localhost:2376
- name: DOCKER_CERT_PATH
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: 256Mi
limits:
memory: 1Gi
volumes:
- name: data
persistentVolumeClaim:
claimName: act-runner-data
- name: certs
emptyDir: {}
- name: config
configMap:
name: act-runner-config
- name: dind-storage
emptyDir: {}