IR-20261111: chore(forgejo-runner): replicas 8 -> 10 (restore CI slots after capacity 1) #949
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!949
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/IR-20261111-runners10"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
homelab-3c's PR takes the runner pool's
capacityfrom 2 to 1 (two host-mode jobs were sharing one runner container's 2-CPU limit, and the Sendrast e2e timed out). At 9 pods that is 16 → 9 concurrent job slots if nothing else changes. This PR restores part of that loss by adding pods: 8 → 9 replicas.Why 9 and not 10 (round 2, after prod-wk-01 came off the pool). Each runner pod requests 14Gi of ephemeral storage (10Gi runner + 4Gi dind). With wk-01 excluded the eligible nodes are wk-02, wk-05, wk-06, and 14Gi is the binding leg:
Nine pods means one new ordinal: it can land on any eligible node and every case stays ≤86.7% of allocatable ephemeral. Ten means two, and the ceiling the fleet sizes ephemeral against (≤90% requested per node) then holds only in some placements: wk-05 physically cannot take a second (102.7%), and a second on wk-06 is 95.7%. wk-02 is not a dependable safety valve — it is at 99% actual CPU (
kubectl top node: 7929m of 7950m) and its Longhorn disk is already over its booking limit (below), so a soft spread that points both new pods there is not a placement anyone can rely on. 10 does not certify ≤90% without wk-01; 9 does. 9 is the max that does, and the overseer's brief explicitly allows it. 8 → 9 costs one slot against the 10 the first round of this PR assumed.The change — three files, all saying the same number
No requests, no limits, no images, no storage class, no runtime config, no RBAC, no NetworkPolicy, no
.forgejo/**, no Terraform, no DB.configmap.yaml's stale "capacity 2 x 8 replicas" comment is deliberately untouched: it is the act_runner runtime config, and a comment there belongs in its own change, not in a replica-count fix.prod-wk-01 is off the pool (required, not preferred)
prod-wk-01hosts sbx-gits, whose Android emulator was OOMKilled at 20:20Z on 2026-11-11; with one more runner its memory requests would reach 86.9%. The exclusion is a requiredNotIn [prod-wk-01], plus wk-01 removed from theInlist, so a future widening of that list cannot silently re-admit it.It is in the SAME
nodeSelectorTermon purpose.nodeSelectorTermsare OR'd;matchExpressionsinside a term are AND'd. ANotInin its own term would widen the eligible set to "every node except wk-01" and let pods onto wk-03 (85% memory requests) and wk-04 (3.8Gi memory allocatable) — the two nodes this affinity exists to exclude. The soft topology spread (maxSkew: 1,whenUnsatisfiable: ScheduleAnyway) is untouched, for the HOMELAB-2418 reason: existing pods are volume-pinned bylonghorn-runner(strict-local), so a hard skew constraint would refuse to place restarts.Today's eight pods are unchanged by the affinity edit and stay where their volumes are: wk-05 = 4 (ordinals 0,2,3,6), wk-06 = 4 (1,4,5,7), wk-01 = 0, wk-02 = 0. The new ordinal 8 has no PVC yet (
WaitForFirstConsumer), so the scheduler is free to choose. wk-02 is the spread's first choice (0 runners) and wk-05/wk-06 the storage-steered one; both placements are sized below.Per-node headroom — BEFORE (live, 8 pods, 2026-11-11 ~22:5xZ)
kubectl describe node(allocatable / requests) +kubectl top node(actual working set) + Prometheusnode_memory_MemAvailable_bytes.top: alloc − working set)topis the working-set basis the kubelet's ownmemory.availablesignal uses, so "free mem" here is the number its eviction threshold reads (it sits 2.1-2.3GiB above/proc MemAvailable—capacity-policy-20261110/remodel-c.md).Per-node headroom — AFTER (+1 pod = 2048Mi / 1500m / 14Gi requests; 10.37Gi / 10c / 24Gi limits)
Every eligible node stays inside allocatable and inside the ≤90% ephemeral bar. wk-05 is now at its last pod (one more after this is 102.7%, unschedulable), which is the constraint that sets the count at 9.
The Talos OOM question — can +1 pod push a worker into the OOM zone?
No. One new pod adds 2048Mi of requests and, at the pool's measured worst pod-cgroup peak of 2258.79Mi (7d), ~2.2Gi of real memory — against 13.5-41.5Gi of free memory on every eligible node.
memory.maxset: 2560Mi (runner) + 8Gi (dind) = a 10.37Gi pod ceiling. A job that tries to take 20Gi is OOM-killed inside its own pod cgroup, which costs one CI slot and touches nothing else.wk02-oom-20261110/,talos-oom/trigger-proposal.md) ismemory_full_avg10 > 75 && time_since_trigger > duration("10s")(or the QoS-full-vector clause) — memory PSI full ≥75% for 10s, which on a 24GiB node lands at MemAvailable ≈1.2-1.4Gi (capacity-policy-20261110/remodel-c.md; measured there: signal 9.5%, MemAvailable 0.68Gi when it fired). After this change the smallest eligible node still has ~5.7Gi of memory headroom in the worst placement — roughly 4x above the trigger depth — and 13.5Gi is the free-memory number on wk-02 today.memory_max.hasValue() ? 4.0 : 1.0) while Guaranteed/Podruntime/System rank 0.0, so the runner is at the front of the kill queue and Longhorn's instance-manager (Guaranteed, system-node-critical) at the back — the orderingcapacity-policy-20261110/brief.md§2/§3 requires.Longhorn booking for the new ordinal (round-2 confirmation)
The new ordinal provisions
runner-data1Gi +docker-data30Gi onlonghorn-runner, which is strict-local, 1 replica — so the booking cost is 31Gi on one node, not 62Gi. Live read (nodes.longhorn.iostatus + theLonghornDiskSchedulingHeadroomexpression fromcore/manifests/monitoring/rules/longhorn-alerts.yaml, bookable =(storageMaximum − storageReserved) × 110% − storageScheduled):Schedulable=Falseright now: "not schedulable for more replica; ScheduledTotal = 492042190848 (Size + StorageScheduled) is greater than ProvisionedLimit = 472155741798"Confirmation, stated plainly: physically there is ample space on wk-05 (116.7Gi) and wk-06 (179.9Gi) for one 31Gi volume, so Longhorn can place it and this PR does not risk data. But the cluster is booked to its over-provisioning ceiling (HOMELAB-2249 records this as by-design: 0/6 disks meaningfully bookable), and wk-02 has actually tripped the guard. So the one new PVC may be refused or delayed by Longhorn's provisioned-limit check, and the symptom would be ordinal 8 sitting Pending — i.e. the pool stays at 8 slots — not a memory, OOM or data event. This changes nothing about the replica arithmetic above (9 needs one 31Gi booking, not two), and it is pre-existing cluster state, not something this PR creates. Levers, per HOMELAB-1852, to decide in the gap if ordinal 8 goes Pending: lower
storageReservedon wk-05 (80Gi) / wk-06 (96Gi), grow the disk, or place the new pod on wk-03 (161.8Gi bookable, excluded here only by the 85%-memory-request rule). Perstorageclass.yaml's own documented step, the new cache volume should be labelledrecurring-job-group.longhorn.io/default=disabledafter it provisions, so 30Gi of regenerable cache stays out of the B2 backup set.dind-gc now follows the replica count (closes round 1's known coupling)
Round 1 left it broken on purpose and flagged it. This head fixes it:
dind-gc-cronjob.yaml—RUNNER_COUNT: "8"→"9", with the reason written next to it (the sweep probes ordinals0..RUNNER_COUNT-1over pod DNS, so a pod above that number is never visited and itsdocker-datagrows without bound — the HOMELAB-2481 leak class).dind-gc-script.yaml— the shell default moves 8 → 9 with it, the ordinal loop is documented asRUNNER_COUNT-driven (it already was,while [ "$ordinal" -lt "$RUNNER_COUNT" ]— no hard-coded 0..7 range existed at this head), andmain()now rejects a non-numericRUNNER_COUNT(exit 2) instead of silently sweeping a garbage range.test_runner_dind_gc_tightening.py(A5) — the assertion used to hardcodeRUNNER_COUNT = "8", which is why a replica bump could leave the CronJob behind and still pass CI. It now readsstatefulset.yaml spec.replicasand fails if the CronJob env or the script default disagree with it, and if the ordinal loop stops referencingRUNNER_COUNT. Two new--self-checkcases prove both legs can fail (CronJob left at 8; loop hard-coded to-lt 8).--self-checkrejects 31 bad inputs; the suite passes.The GC job's node affinity (
In [prod-wk-05, prod-wk-06]) is unchanged and still correct: it reaches every ordinal over pod DNS, wherever the pod runs.Rollout — this one DOES bounce the pool
kubectl apply/scale, no cluster change from this branch; ArgoCD applies it in the runner gap (homelab-a9 gets the 30-minute notice).podManagementPolicy: Parallelthe update pass is not gated on each pod becoming Ready. Expect the whole pool to bounce together, in-flight jobs killed, and pods returning to their own nodes (strict-local PVCs pin them). That is the cost of the wk-01 exclusion and it belongs in the gap, next to 3c's capacity change.Class and landing
.forgejo/**, no runtime config, no PVC delete.test_runner_dind_gc_tightening.py+--self-check,test_runner_placement_prep.py,test_runner_ephemeral_ceilings.py), andshellcheckis clean on the embedded GC script.model:and a baresession:), then El Director'sLD-GO. The head commit's last trailer isLab-Run: IR-20261111-runners10.APPROVE — ld-reviewer — head
a290cc94d6Reviewed the diff, the head files and the commit statuses independently at
a290cc94d6(branchplane/IR-20261111-runners10, two commits:d77bd1a756round 1,a290cc94d6head). I did not read any existing comment or review on this PR; this verdict comes only from the diff, the statuses and the body.1. Scope — exactly four files, nothing else. The files listing and the raw diff agree:
core/manifests/apps/forgejo-runner/dind-gc-cronjob.yaml(+10/−3),dind-gc-script.yaml(+18/−1),statefulset.yaml(+36/−2),scripts/manifests/tests/test_runner_dind_gc_tightening.py(+59/−6). changed_files = 4, and stripping comment lines leaves only:replicas: 8→9, the two affinity expressions,RUNNER_COUNT"8"→"9"in the CronJob env and in the script default, thecase "$RUNNER_COUNT"guard inmain(), and the test rewrite. Pass.2. No changes to the prohibited areas. No hunk touches resources, images, storage, RBAC, NetworkPolicy,
.forgejo/, or terraform. At head the container resources are unchanged and still sum to the documented pod request/limits: runner 500m / 1003Mi / 10Gi requests, 2c / 2560Mi / 14Gi limits; dind 200m / 1045Mi / 4Gi requests, 8c / 8Gi / 10Gi limits; both images remain digest-pinned; bothvolumeClaimTemplateskeepstorageClassName: longhorn-runner(1Gi runner-data, 30Gi docker-data); theNotInsits inside the same singlenodeSelectorTermas theInlist, so it narrows rather than widens, and the softtopologySpreadConstraints(maxSkew: 1,ScheduleAnyway) is byte-for-byte untouched. Pass.3. Headroom arithmetic — recomputed, and the ephemeral leg that decides 9 is consistent. wk-02: 12.0/92.4 = 13.0%, +1 = 26.0/92.4 = 28.1%, +2 = 40.0/92.4 = 43.3%. wk-05: 62.1/87.8 = 70.7%, +1 = 76.1/87.8 = 86.7%, +2 = 90.1/87.8 = 102.6% (above allocatable, i.e. unschedulable there). wk-06: 56.0/87.8 = 63.8%, +1 = 70.0/87.8 = 79.7%, +2 = 84.0/87.8 = 95.7%. So after +1 no eligible node exceeds 86.7% and wk-05 is indeed on its last slot; +2 fails the ≤90% bar on both wk-05 and wk-06. The 9-not-10 conclusion holds on the numbers. One column is wrong, in the safe direction: the "+1 pod = 2048Mi / 1500m / 14Gi requests" line disagrees with the manifest, which sums to 700m cpu requests (500m runner + 200m dind); memory (2048Mi) and ephemeral (14Gi) match exactly. Because the stated cpu delta is larger than the real one, that column overstates consumption and errs toward refusing the 10th pod — it cannot have let a bad placement through, and it does not move the ephemeral leg that sets the count. Worth correcting in the merged record.
4. The pool bounce is disclosed. The "Rollout — this one DOES bounce the pool" section and the commit message both say plainly that the pod-template change replaces every replica, kills in-flight jobs, and belongs inside the runner gap, and that merging alone changes nothing until ArgoCD syncs. One mechanism refinement:
podManagementPolicy: Parallelupstream "only affects the behavior for scaling operations. Updates are not affected", so the replacement is reverse-ordinal and readiness-gated per pod rather than one simultaneous swap. That changes the shape of the outage, not its operational meaning — every replica is still replaced and every in-flight job is still killed inside the gap, which is the part the plan depends on and the part that is disclosed. Pass.5. CI on the exact head: green.
GET /repos/Homelab/infra-core/commits/a290cc94d6efeb71de52860213a150b7c544c0c2/statusreturnsstate=success,total_count=11, with all eleven contexts success: runner-manifest-test, helm-validate, receiver-test, alert-receiver-test, shellcheck, yaml-lint, record-sql-test, terraform-validate, cockpit-e2e, cockpit-test, dispatcher-test. The statuses list for that same sha returned 33 rows whose latest entry per context is success, finishing 2026-11-11T22:11Z–22:14Z. No pending or failing context remains.6. Class claim — routine, agreed. The diff is a replica count, one affinity term, a GC count plus a numeric guard, and a CI assertion. No RBAC, no secrets, no NetworkPolicy, no Terraform, no DB/schema, no
.forgejo/**, no PVC delete, no image or resource change. Routine fits.Two small things to pick up when convenient, neither affecting this verdict: the PR title still reads "replicas 8 -> 10" from round 1 while the diff is 9 (the body is explicit, but the title is what the merged history will show), and
_ordinal_loop_blockproves the intent by matching anywhile/forline containingRUNNER_COUNT, which is coarse — it is tight enough today only because the new self-check case pins the hard-coded-8 mutation, and that coupling deserves a comment before someone adds a second RUNNER_COUNT-driven loop.Verdict stands at this exact head: approve.
model: omniroute/deepseek-flash
session: IR-20261111-runners10-seatA-20261112T0226Z
APPROVE — ld-reviewer — head
a290cc94d6Seat B, independent read at the pinned head. The head I read equals the pin exactly (
a290cc94d6efeb71de52860213a150b7c544c0c2), baselive@3563ec7. Two commits on the branch (d77bd1a7round 1,a290cc94round 2); PR diff is +123/−12.Diff is exactly the four files, nothing else.
core/manifests/apps/forgejo-runner/dind-gc-cronjob.yaml(+10/−3),core/manifests/apps/forgejo-runner/dind-gc-script.yaml(+18/−1),core/manifests/apps/forgejo-runner/statefulset.yaml(+36/−2),scripts/manifests/tests/test_runner_dind_gc_tightening.py(+59/−6). The body's "three files" heading undercounts; the fourth is the CI assertion, and the file list has four.No protected surfaces touched. No requests/limits change, no image change (both container digests identical before and after), no storageClass or volumeClaimTemplates change, no RBAC, no NetworkPolicy, no
.forgejo/**, no Terraform, no DB.configmap.yamlis untouched, as the body states. In the StatefulSet the only spec edits arereplicas: 8 -> 9and the affinity block.Affinity shape is the safe one. I read the whole file at the head, not just the diff: a single
nodeSelectorTermcarriesIn [prod-wk-02, prod-wk-05, prod-wk-06]andNotIn [prod-wk-01]as twomatchExpressionsat the same level, so they AND.topologySpreadConstraintsis unchanged (maxSkew: 1,ScheduleAnyway). ANotInin its own term would indeed have widened the set, and the in-file comments say so.Headroom arithmetic. The ephemeral leg — the one that decides 9 against 10 — recomputes exactly on every eligible node: at 14Gi per pod and the allocatables in the table, +1 pod is wk-02 26.0/92.4Gi = 28.1%, wk-05 76.1/87.8 = 86.7%, wk-06 70.0/87.8 = 79.8%; +2 is wk-05 102.6% (body says 102.7%, rounding — over 100% either way, i.e. unschedulable) and wk-06 95.7%. So every eligible node stays ≤86.7% after one pod and wk-05 is at its last. The memory columns recompute exactly too (48.0% / 61.8% / 73.6%). One derived column does not: "headroom left (mem)" of 12.5 / 20.4 / 5.7Gi is not allocatable−requests, which I compute as ~12.0 / 22.3 / 6.1Gi. The deviation is sub-GiB to ~1.9Gi and runs in both directions, and it crosses no bar — the smallest residual (~6Gi on wk-06) is still several times the Talos OOM trigger depth the body quotes. Worth recomputing if the body is reprinted; no claim that depends on it changes.
Bounce disclosure is honest and specific. The body states plainly that the pod-template change (plus
podManagementPolicy: Parallel, not gated on readiness) replaces every replica in reverse ordinal order, kills in-flight jobs, and belongs in the runner gap; the commit message repeats it. Merge alone changes nothing until ArgoCD syncs. It is not understated anywhere.dind-gc follows the replica count. At this head: CronJob
RUNNER_COUNT: "9"== script default${RUNNER_COUNT:-9}==spec.replicas: 9. The ordinal loop iswhile [ "$ordinal" -lt "$RUNNER_COUNT" ](it was already count-driven — the diff documents that rather than changing it),main()now exits 2 on a non-numeric count before any sweep, and the assertion derives the width fromspec.replicasinstead of a literal. The old module-levelRUNNER_COUNT = "8"is gone, and nothing inscripts/manifests/tests/(three files there) imports or references it, so no dangling NameError. Both new--self-checkcases genuinely fail their mutation: CronJob env 8 against replicas 9, and a loop rewritten to-lt 8, which no longer references RUNNER_COUNT.CI on the exact head. I read the commit-statuses API for
a290cc94d6efeb71de52860213a150b7c544c0c2: combined statesuccess, 11 of 11 latest contexts success — CI Review: runner-manifest-test, helm-validate, receiver-test, alert-receiver-test, record-sql-test, dispatcher-test, cockpit-test, cockpit-e2e; Lint & Validate: shellcheck, yaml-lint, terraform-validate. Thependingrows visible per context are older than that context's success row. I did not run the suites inside this pod (no PyYAML here); the runner-manifest-test job at this head is the executed evidence, and the static reading above is separate.Class claim matches the diff. Routine is right: one integer, one required-affinity expression pair, a GC count plus default plus input guard, and its CI assertion. The only material risk is the required-affinity narrowing, and it is disclosed with failure mode and levers (ordinal 8 can sit Pending on Longhorn's provisioned-limit check, pool stays at 8 slots, no memory/OOM/data event). The whole-pool restart is operational and is placed in the gap. Nothing here is the dangerous class (no authz, secrets, data surface, or Terraform).
Approving at this head. No seats and no merge from me.
model: omniroute/deepseek-flash
session: IR-20261111-runners10-seatB-20261112T0226Z