Spike t4-ci — kind and Kueue on GitHub-hosted runners (2026-10-28)¶
Question. Can pipeline step T4 (next-phase.md, "T4 · kind and Kueue") be done CI-driven on GitHub-hosted runners, which is option (b) of inbox #51? A Claude Code cloud session cannot run kind, because its cgroup layout is hybrid v1 (spike D2). In detail:
- create
k8s-miniandk8s-kueue(dev-environment-and-testing.md§4.2) in a GitHub Actions job; - run the synthetic Nextflow pipeline with
-profile k8s; - bring the pod, Job, event, OOMKilled and Kueue Workload fixtures back through the job log;
- check the runner and engine-head ServiceAccounts of
schedulers-and-monitoring.md§4.
Answer in one line. Yes, feasible. In 2 pushes, every item of T4's prompt ran on ubuntu-24.04 runners, except nf-core/demo, which pipelines-real.yml already covers. A loop costs 4–5 minutes of wall-clock time and no money: the repository is public, and standard runners are free there. The §4 engine-head Roles are not sufficient: both Nextflow versions need pods/status: get. The runner Role cannot read Kueue Workloads.
This is a spike, not T4. Nothing in pipeline.yaml, the specs or the inbox was changed. Thomas has not answered #51.
Setup¶
- Branch
spike/t4-ci, fromautopilot/integrationat504bc62. The files are inspikes/t4-ci/, plus one workflow,.github/workflows/spike-t4-ci.yml. The workflow runs onpushto that branch (pathsspikes/t4-ci/**and itself) and onworkflow_dispatch. It haspermissions: contents: readand no secrets. Each job hastimeout-minutes: 40. Actions are pinned by SHA, with the pins copied fromdev-env.yml, andrun:blocks contain no${{ }}. The fixture names andspikes/t4-ci/paths this note cites are on branchspike/t4-ci; the fixtures committed with T4 are intests/fixtures/k8s/. - Runner.
ubuntu-24.04, image version 20261025.353.1 (from the job log). Measured: 4 CPUs, 15 GB RAM, 86 GB free on/, Docker 28.0.4 on cgroup v2. GitHub documents 4 CPU and 16 GB for standard Linux runners in public repositories, against 2 CPU and 8 GB in private ones, and says that "Use of the standard GitHub-hosted runners is free and unlimited on public repositories" (GitHub-hosted runners reference). The run timing API reportedbillable: 0 msfor both runs. -
Pins, read from
dev/versions.envand checked withsha256sumthe same waydev/bootstrap.shchecks them (spikes/t4-ci/cluster.sh install):Tool Version kind 0.33.0, node image kindest/node:v1.37.0@sha256:a1ed56cf…kubectl 1.37.1 Nextflow 26.10.4 and 25.10.9 Kueue is v0.20.3 (
KUEUE_VERSION), installed withkubectl apply --server-sideof the release'smanifests.yaml(release v0.20.3). No checksum is published for that file, so the spike pins the sha256 of the download made on 2026-10-28 (646840e4…, "computed", as inversions.env). The manifest serveskueue.x-k8s.io/v1beta2and registers the APIServicev1beta2.visibility.kueue.x-k8s.io. Its defaultintegrations.frameworkscontainsbatch/joband notpod(manifests.yamlL27147–L27154 at v0.20.3). -
Clusters (
cluster.sh up): kind with the runner's/home/runner/workmounted into every node at the same path (extraMounts).mini: one node (k8s-mini).kueue: a control plane and 2 workers, then Kueue, aResourceFlavor, two ClusterQueues and two LocalQueues (manifests/kueue-queues.yaml):cq-mainhas 2 CPUs of quota andcq-tighthas 1.- Both have the namespace
spectio-workwith aResourceQuota(manifests/base.yaml).
What ran¶
The workflow has three parallel jobs, each with its own cluster.
| Job | Shape | Scenarios (spikes/t4-ci/scenarios.sh) |
|---|---|---|
k8s-mini nextflow |
mini | failures (plain OOM, deadline and exit-3 objects), nf-happy (26.10.4), nf25-happy (25.10.9, run 2), nf-oom (oom_real) |
k8s-kueue |
kueue | kueue-wait (3 one-CPU Jobs on the 1-CPU cq-tight), nf-kueue (synthetic pipeline through lq-main) |
rbac |
mini in run 1, kueue in run 2 | can-i matrix; the runner's own work done with its token; each Nextflow head run under its own ServiceAccount token with three Role sets |
| Run | Commit | Result | Wall clock (push to last job end) | Jobs (cluster up / scenarios) |
|---|---|---|---|---|
| 40662141233 | fdc55d3 |
success | 3 min 49 s (229 s) | nextflow 3:45 (57 s / 2:31), kueue 2:00 (74 s / 37 s), rbac 2:29 (50 s / 86 s) |
| 40663207755 | d43ef90 |
success | 5 min 09 s (309 s) | nextflow 5:07 (49 s / 4:07), kueue 1:57 (62 s / 43 s), rbac 4:32 (70 s / 3:05) |
- Fixed cost of a loop. About 1 minute: installing the tools takes 5–10 s, the kind cluster 37–45 s, and Kueue another 21–28 s until its webhook and visibility APIService answer. The
python:3.12task image (1.6 GB) is pre-pulled on the worker nodes in parallel, in about 20 s. - Time to start. The first job started 2–3 s after each push.
-
Scenario times:
Scenario Time failures43 s nf-happy67–80 s (65 tasks) nf25-happy80 s nf-oom38–39 s kueue-wait1–3 s nf-kueue36–39 s rbac86 s in run 1, 185 s in run 2 (2 Nextflow versions × 3 Role sets × 2 runs)
Results¶
The k8s profile runs on kind, and its bin/ open item is solved¶
Open item 6 of the T1 note is closed for kind. The head runs on the runner, outside the cluster, with -profile test,k8s plus -c spikes/t4-ci/nextflow/kind.config. That config does not touch the profile; it adds:
k8s.namespace;k8s.cleanup = false, so pods and Jobs stay for the dumps;- a pod
hostPathvolume of/home/runner/workmounted at the same path (PodOptions.groovyL126–L127 at v26.10.4); executor.pollInterval = '2 sec';- optionally, the Kueue queue label.
The launch directory, the work directory and the pipeline, including the bin/synth.py link target, all sit under that mount. Every path the head writes into .command.run therefore resolves inside the task pod (task pod spec in fixture nf26-happy-task-pods). This only works where every node can see the runner's disk, which is the case for kind. A real cluster needs the RWX volume of k8s-rwx or Fusion (§4.2).
| Run | Result |
|---|---|
nf26-happy, Nextflow 26.10.4 |
exit 0, 65 / 65 tasks COMPLETED, one Kubernetes Job per task (65 Jobs) |
nf25-happy, Nextflow 25.10.9 |
exit 0, 65 / 65, bare Pods and no Job |
nf26-oom, oom_real on QC_STATS S05 |
exit 1. The 3 attempts ran with memory limits of 512Mi, 1Gi and 1536Mi (the config's 512.MB * task.attempt). Every pod state.terminated is reason: OOMKilled, exitCode: 137, and the trace shows FAILED 137 at attempts 1–3. This is the first proof on Kubernetes of oom_real, open item 5 of the T1 note. |
- Why 25.10.9 needs the plugin named. Without it, 25.10.9 stops with "Unknown executor name: k8s" (run 1): its log says
Plugins default=[]andExtension executors providers=[]. With-plugins nf-k8s@1.2.3it runs; 1.2.3 is the version that 25.10.9's own log lists among its core plugins. 26.10.4 needs nothing: its log saysExtension executors providers=[K8sExecutor]with no plugin download. Where 26.10 bundles the executor is [to be validated: source location]. - CPU limits. Nextflow sets
requests.cpu,requests.memoryandlimits.memory, and no CPU limit (thecpuLimitsoption is off by default,K8sConfig.groovyL226 at v26.10.4). - Scheduling. On the one-node
k8s-mini, 58 of the 65 pods first gotFailedScheduling: 0/1 nodes are available: 1 Insufficient cpu(each task requests 1 CPU), and then ran. That event is normal back-pressure, not an error. Spectio must not show it as a failure.
Plain failure objects (k8s-mini row of §4.2)¶
Fixtures k8s-failures-pods, k8s-failures-jobs and k8s-failures-events. Identical in both runs:
| Object | Pod | Job |
|---|---|---|
fx-oom (busybox tail /dev/zero, limit 32Mi, backoffLimit: 0) |
phase: Failed, state.terminated.reason: OOMKilled, exitCode: 137 |
Failed, reason BackoffLimitExceeded |
fx-exit3 (exit 3, backoffLimit: 1) |
2 pods, reason: Error, exitCode: 3 |
Failed, reason BackoffLimitExceeded |
fx-job-deadline (Job activeDeadlineSeconds: 10) |
deleted by the Job controller (event SuccessfulDelete) |
Failed, reason DeadlineExceeded ("Job was active longer than specified deadline") |
fx-pod-deadline (Pod activeDeadlineSeconds: 10) |
status.reason: DeadlineExceeded, but the container's terminated.reason is Error, exit 137 |
— |
- No OOM event. The OOM kill produced no event in the namespace: it appears only in the pod's container status. A runner that keeps only events would miss it, so §4's rule to keep "the last terminal state of each pod" is necessary.
- Failed Jobs carry two conditions. Both
FailureTargetandFailedare present, with the same reason.
Kueue: Workloads waiting for quota, with positions¶
kueue-wait (fixtures kueue-workloads, kueue-pending-lq-tight, kueue-pending-cq-tight, kueue-clusterqueue-cq-tight, kueue-localqueue-lq-tight, kueue-jobs and kueue-events).
The Workloads.
- The admitted one:
QuotaReserved=True,Admitted=True,PodsReady. - The two waiting ones:
QuotaReserved=False, reasonWaitingForQuota, message "couldn't assign flavors to pod set main: insufficient unused quota for cpu in flavor default-flavor, 1 more needed";Admitted=False, reasonNoReservation.
The visibility API. GET /apis/visibility.kueue.x-k8s.io/v1beta2/namespaces/spectio-work/localqueues/lq-tight/pendingworkloads returns a PendingWorkloadsSummary with positionInLocalQueue and positionInClusterQueue 0 and 1, plus priority, localQueueName and the owner Job. The same holds for the ClusterQueue path. This confirms §4's version rule for v0.20 (v1beta2).
Two timing facts for the runner:
- The visibility API knows first. Run 1 dumped 1 s after the Jobs were created. One waiting Workload already had a visibility position but no
QuotaReservedcondition yet, and the ClusterQueue status still saidpendingWorkloads: 0. Run 2 waits for a settled state before dumping. Duringnf-kueue, 3 of the 4 Workloads in the visibility answer also had onlyPodsReady=False/WaitForStart(fixturenf26-kueue-pending-workloads). So a runner that reads only Workload conditions shows "queued" later than one that reads the visibility API. -
Kueue manages Nextflow 26.10 tasks with no change to Nextflow.
- The queue label set through
k8s.podends up on the Job (kueue.x-k8s.io/queue-name: lq-maininmetadata.labels). This confirms §4's claim that Nextflow builds the Job from the pod metadata. - Each of the 14 Jobs got
Suspended→CreatedWorkload→Resumed→FinishedWorkloadevents. - Up to 5 Nextflow Workloads waited at the same time on the 2-CPU
cq-main, and the run still ended with exit 0 in 36–39 s.
Nextflow 25.10's bare Pods were not tried through Kueue (its
podintegration is off by default, above). - The queue label set through
ServiceAccounts (§4)¶
manifests/rbac-spec.yaml copies the §4 Roles verbatim. Each check below ran with the ServiceAccount's own bound token (kubectl create token), not only kubectl auth can-i --as.
The head runs on the runner with a kubeconfig that holds the head ServiceAccount's token. For the API server this is the same identity as a head pod with that ServiceAccount. Each Role set got two runs of the 2-sample sheet (nextflow/mini-samplesheet.csv, 14 tasks):
- a success run, which on 26.10 goes through
lq-main, so its Jobs wait suspended with no pod; - a tool-error run (ALIGN S07 exits 1), so the head kills the tasks still in flight.
spikes/t4-ci/api_calls.py turns the trace into the list of calls made. It reads the -trace nextflow.k8s.client lines ("[K8s] API response METHOD path", K8sClient.groovy L672–L674) and the refusals ("returned an error code=403", L664–L668).
| Head | §4 Role (spec) | Calls actually made (run 2) | Minimal Role, verified |
|---|---|---|---|
| Nextflow 26.10.4 | not sufficient: success run exit 1 in 7 s, pods/status get refused (403), pods delete refused (403); Jobs left behind ("Unable to delete job") |
jobs create, delete; jobs/status get (28, only under Kueue); pods list, delete; pods/status get |
jobs: create, delete; jobs/status: get; pods: list, delete; pods/status: get: success exit 0, error run exit 1, 0 refusals |
| Nextflow 25.10.9 | not sufficient: exit 1 in 8 s, pods/status get refused (403) |
pods create, delete; pods/status get |
pods: create, delete; pods/status: get: exit 0 / 1, 0 refusals |
-
Where the extra calls come from:
pods/status: every task poll reads/api/v1/namespaces/<ns>/pods/<name>/status(K8sClient.groovyL327–L333 at v26.10.4).jobs/status: read while a Job has no pod yet, which is the Kueue case (jobStateFallback0, L411–L445).pods: deleteon 26.10:jobDeletedeletes the Job's pods one by one before the Job (L234–L262).
-
Never used by the head:
watch, plaingeton pods or Jobs,liston Jobs, andpods/log. Nextflow polls; it does not watch. The §4 grants of these are therefore unnecessary. Each verb in the minimal Role was used in at least one of the runs. - What the minimal Role does not cover: code paths that these runs did not hit, for example
k8s.fetchNodeName(getNodeOfPod) ornextflow kuberun. -
Runner Role: the §4 Role is sufficient for its listed work and refuses everything outside it. With its token:
- allowed: create the head Job (with
serviceAccountName: nf-head-26), wait for it, list its pods, read its log, read events, ResourceQuotas and LimitRanges, and delete it; - refused (403):
create pods,get secrets,create pods/execandlist nodes; - also refused:
workloads.kueue.x-k8s.io(Forbidden) and the visibilitypendingworkloads(Forbidden). §4 says that the runner watches Workload objects, but its Role does not grant that. The visibility rule is listed there as optional, so that refusal is expected.
- allowed: create the head Job (with
-
Task pods get a token. The task pods run as the namespace's
defaultServiceAccount, with its token mounted (kube-api-access-*projected volume innf26-happy-task-pods). Whether Spectio should setautomountServiceAccountToken: falsefor task pods is a question for the security review [to be validated].
How fixtures come back¶
-
Log blocks. Every fixture is printed once in the job log, between
=====SPECTIO-FIXTURE-BEGIN name=… bytes=… items=kept/total=====and=====SPECTIO-FIXTURE-END name=…=====(spikes/t4-ci/fixture.py):managedFieldsand the last-applied annotation are dropped;- each block is capped at 60 kB, by dropping
itemsfrom the end and recording_spike.truncated. - Run 2 printed 41 blocks, from 49 B to 36 kB; one was truncated (8 of 14 finished Workloads). The three job logs total 0.96 MB.
-
Back to files.
spikes/t4-ci/extract_fixtures.py LOG OUT_DIRstrips the runner timestamps and writesOUT_DIR/<name>.json. On the logs of runs 1 and 2 it wrote 31 + 41 files with 0 broken blocks. - Getting the logs. From this cloud session,
curl https://api.github.com/repos/weber8thomas/spectio/actions/jobs/<id>/logs(through the agent proxy) returned the full plain-text log once each job had finished (HTTP 302 to a signed blob URL, then 200). The MCPget_job_logstool works too, but returns a tail. - Artifacts work too.
GET /repos/weber8thomas/spectio/actions/artifacts/<id>/zipalso returned 200 with the zip (tested on an existingscenarios-snakemake-9.27.1artifact), so they are a second path. This contradicts #51's "cannot download artifacts" for this session. The proxy answers only for repositories attached to the session; forkubernetes-sigs/kueueit said "GitHub access to this repository is not enabled for this session". Whether every cloud session gets this is [to be validated]. - For T4 proper. Give each fixture a name with the tool version (
<scenario>-k8s1.37.0-kueue0.20.3.json), then extract and normalise it (drop UIDs, names with hashes, timestamps and IPs), as T1 does for the trace golden files.
What did not work, and why¶
- Nextflow 25.10.9 with
-profile k8sas it stands: "Unknown executor name: k8s" (above). The profile, or Spectio's generated config, must namenf-k8sfor 25.10. - The §4 engine-head Roles: refused on
pods/statuson both versions, and onpods deleteon 26.10 (above). The 26.10 run under the spec Role also leaked Jobs, becausejobDeletecould not delete their pods. - The first Kueue dump was taken too early (above). Fixed in run 2 by waiting for a settled state.
- Not run:
- nf-core/demo
-profile test,docker:pipelines-real.ymlalready runs it in its PR tier (corpus.yaml:demo,tier: pr) and nightly, with-profile test,docker(corpus CI note: pass in 4–6 min). k8s-rwxand SeaweedFS, and the Snakemake Kubernetes executor: these are §4.2 items, but not in T4's prompt.- Kueue preemption, and Kueue
VisibilityOnDemandlimits. - The previous Kueue minor (
KUEUE_PREVIOUS_VERSION0.19.10).
- nf-core/demo
Result¶
Option (b) for T4: feasible.
-
Coverage. In two push iterations, a GitHub-hosted
ubuntu-24.04runner (cgroup v2) did everything T4's prompt asks, except nf-core/demo, which is covered elsewhere:k8s-miniandk8s-kueue, with Kueue v0.20.3, a ClusterQueue and a LocalQueue;- the synthetic pipeline on Kubernetes, on Nextflow 26.10.4 and 25.10.9;
- real
OOMKilled, with exit 137; - pod, Job and event fixtures;
- Workloads waiting for quota, with their visibility positions;
- both §4 ServiceAccounts, tested with real tokens.
-
Cost per loop. 4–5 minutes from push to result, three jobs in parallel. About 1 minute of that is fixed (cluster and Kueue); the rest is the scenarios. $0, because the repository is public. On a private repository each loop would use about 12 runner-minutes from the free allowance, on 2-CPU runners. That is [to be validated]: this spike did not measure 2-CPU runners.
-
What (b) costs compared with (a).
- A local loop is seconds, not minutes.
- CI cannot be inspected interactively: no
kubectlinto a live cluster, so every question becomes a scripted dump and a push. - Fixtures come back through the log or as an artifact, and both can be fetched from this session.
-
For T3 and T4. T4 is a small number of well-defined captures, so (b) is practical: this spike needed two loops. T3 has more failure modes per loop, so it would likely need more iterations, but the same pattern applies [to be validated].
Findings for TRIAGE (specs not edited)¶
-
§4 engine-head Roles are not sufficient (schedulers-and-monitoring §4). Replace them with the minimal sets verified above:
- 26.10:
jobs: create, delete;jobs/status: get;pods: list, delete;pods/status: get; - 25.10:
pods: create, delete;pods/status: get.
The "[to be validated on k8s-mini]" note on 25.10 needing
pods: createis now verified. - 26.10:
-
The runner Role lacks
workloads.kueue.x-k8s.io: get, list, watch, which §4's "What the runner watches" needs when Kueue is installed. - Nextflow 25.10 needs
nf-k8snamed for the k8s executor (-plugins nf-k8s@1.2.3, or aplugins {}block in the generated config). Thek8sprofile of the synthetic pipeline does not name it. - The OOM kill is visible only in the pod status, with no event; a Job deadline deletes its pod. Both support §4's rule that the runner keeps terminal pod states itself (§4.2 golden fixtures).
- Kueue's visibility API shows a pending Workload before its
QuotaReserved=Falsecondition is set, and before the ClusterQueue'spendingWorkloadscounter includes it. The "queued" state should come from the visibility API when the runner can read it. FailedScheduling: Insufficient cpuis ordinary back-pressure on a small cluster (58 of 65 pods onk8s-mini). Not a failure.- Task pods get the namespace's
defaultServiceAccount token. For the security review [to be validated]. - Inbox #51 says the session "cannot download artifacts". That is not what this session saw: through the proxy, the GitHub REST API returned job logs and artifacts for this repository.