Skip to content

feat(role): measure etcd vote fitness and report the role controller's decisions in dry-run - #185

Merged
Mearman merged 20 commits into
mainfrom
feat/role-controller-signals
Nov 3, 2026
Merged

Mearman merged 20 commits into
mainfrom
feat/role-controller-signals

Conversation

@Mearman

@Mearman Mearman commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Refs #182

The role controller of #179, in dry-run: it collects the fitness signals and reports what it would do, and changes nothing in the cluster.

fleet-role-probe (a DaemonSet on every Linux node with a kubelet, host network) appends one sample every period to its key of the fleet-role-probe ConfigMap: TCP connect time to each voter's peer port, what a voter's etcd metrics gained since the last sample (WAL fsync, backend commit, per-peer round trip, leader changes, leader/learner/known-member gauges), a fio fdatasync reading once per window on a non-voter, the regrade core's own memory readings, and whether the default route leaves by wired, Wi-Fi or an interface whose link can't be seen. A node that is neither a voter nor marked eligible records nothing, so the ConfigMap grows with the hosts that decide the control plane. fleet-role (Deployment) reads those and the Nodes and publishes fleet-role-status in github-runner-platform, which the heartbeat republishes beside the autoscaler's. Per host it gives fit, unfit or unknown with every reason and its numbers, and the swap it would make or why it would not.

Same form as node-recovery and psi-reporter: bash, jq and kubectl in an Alpine image built by the release workflow, installed by the ARC role, ClusterRoles that can only get and list nodes, ConfigMap access by name. It is off unless github_runner_cluster_role_controller is true (the name #184 fixes; github_runner_arc_role_controller_enabled takes it as its default), and the controller refuses to run with anything but FLEET_ROLE_DRY_RUN=true.

Thresholds are derived as the epic states them (a tenth and a twentieth of the election timeout, 10 ms and 25 ms disk, memory against the regrade limit and the storage quota, a window of 100 samples = the fewest that give a p99 its own sample, cooldown = k3s's learner stall time plus a window). Two decisions the epic leaves implicit, both found by running it against real k3s: a slow link makes both its ends unfit, so the voter to replace is the one on the most slow links (and nothing is swapped when that voter is pinned), and a standby is judged against the voters that would remain, since it measures the slow one slow too.

Tests

  • Unit: every threshold at its boundary, hysteresis, window size, odd target, headroom, pinned, cooldown; the memory limit is held to the regrade core's own is_over. The probes are tested against the real thing: a sysfs tree whose links loop like the kernel's (and the host's own /sys where there is one), real listeners and curl, pages from real etcd 3.6.15 and from a follower of a real k3s cluster, a live local three-member etcd (pinned release, SHA-256 checked in CI), the real fio. A jq stand-in refuses an argument over 128 KiB as Linux does, since passing the probe data as arguments failed on a runner and passed on macOS.
  • Mesh scenario role_controller (tests/mesh/run.sh): three servers and a standby agent on real k3s and etcd, the image built from this checkout, the ARC role's rendered manifests. It checks the RBAC with kubectl auth can-i, waits for a full window of real samples with every host fit, makes a voter slow with tc netem on its mesh interface and checks it is reported unfit with the swap named, pauses a voter and checks the controller says it would not act, and that the etcd members and nodes are unchanged. etcd's data (and the standby's fdatasync directory) is on tmpfs in this scenario, since a virtual disk does not reliably meet etcd's 10 ms fsync limit (a laptop's measured 15 to 30 ms) and the scenario tests the pipeline, not the machine's disk. Run locally on a headless Colima profile raised to 4 CPUs and 10 GiB (the default 2 and 4 are too little for four k3s nodes and Headscale); it also passes on the runner. On a laptop under heavy load the baseline can read the voters unfit from real 1 s round trips, which is the controller working, not a flake in it.
  • CI also builds the image without pushing it and runs what the pods run from it (tests/fleet_role/image-smoke.sh).

Inventory and labels

The cluster role holds the controller's inventory: github_runner_cluster_role_controller (off), github_runner_cluster_voters (3), and per host github_runner_cluster_voter_eligible and github_runner_cluster_voter_pinned (both false). While the controller is on, the role refuses an even target, one under 3, one without a place of headroom below github_runner_cluster_max_servers, a datastore that is not etcd, and fewer eligible hosts than the target once any host is marked (with none marked the controller only reports). The two host settings reach the nodes as --node-label on every runtime (Docker server, Docker agent, native), written as true or false on every host that uses the controller because a kubelet never removes a label; a host that uses none of it gets no label and no extra Compose file, so no container is recreated by an upgrade. The ARC role's target and label names default to these. The mesh scenario sets them in its inventory and checks they reach the servers and the agent.

After the first release publishes ghcr.io/exadev/github-runner-fleet-role, the package has to be made public (GitHub's REST API has no endpoint for package visibility, only list, get, delete and restore, so this is the web UI): the organisation's Packages tab, the package, Package settings, Danger Zone, Change visibility, Public (docs, archived). It cannot be undone, as the page says.

Real-host checks (read-only)

Each reading alone, from inside the probe pod on the node, with nothing written to the cluster (fsync writes and unlinks a small file in the probe's own directory). Compare each with the hand reading beside it. Pod for a node: kubectl -n github-runner-platform get pod -l app.kubernetes.io/name=fleet-role-probe --field-selector spec.nodeName=NODE -o name.

probe="kubectl -n github-runner-platform exec POD -- /app/scripts/fleet-role-probe.sh check"

# link: the interface and wired/wifi/unknown
$probe link
ip -o route show default                       # the lowest metric decides
readlink -f /sys/class/net/IFACE               # under /devices/virtual/ means unknown
ls -d /sys/class/net/IFACE/phy80211 /sys/class/net/IFACE/wireless; grep DEVTYPE /sys/class/net/IFACE/uevent

# connect: ms to a voter's peer port (a number under the election timeout, or the timeout itself when it fails)
$probe connect PEER_MESH_IP
curl -s -o /dev/null -w '%{time_connect}\n' --connect-timeout 1 http://PEER_MESH_IP:2380/
ping -c 20 PEER_MESH_IP; tailscale ping --c 10 PEER_MESH_IP

# memory
$probe memory
cat /proc/pressure/memory; grep -E '^(MemTotal|MemAvailable):' /proc/meminfo

# etcd, on a voter: gauges and the tails of etcd's cumulative histograms
$probe etcd
curl -s http://127.0.0.1:2381/metrics | grep -E '^etcd_(server_id|server_has_leader|server_is_learner|network_known_peers|network_active_peers|server_leader_changes_seen_total|disk_wal_fsync_duration_seconds_(sum|count)|disk_backend_commit_duration_seconds_(sum|count))'
etcdctl --cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt --cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt --key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key --endpoints=https://127.0.0.1:2379 member list -w table

# fsync, on a node that is not a voter
$probe fsync
fio --rw=write --ioengine=sync --fdatasync=1 --directory=/var/lib/rancher/k3s/fleet-role-probe --size=230000 --bs=2300 --name=hand --unlink=1 --output-format=json | jq '.jobs[0].sync.lat_ns.percentile["99.000000"] / 1e6'

Then, with the role controller on for at least one window, kubectl -n github-runner-platform get cm fleet-role-status -o jsonpath='{.data.status\.json}' | jq . against the hosts, and kubectl -n github-runner-platform logs deploy/fleet-role.

@Mearman Mearman changed the title feat(role): collect the etcd vote fitness signals and report the role controller's decisions in dry-run feat(role): measure etcd vote fitness and report the role controller's decisions in dry-run Nov 3, 2026
@Mearman
Mearman force-pushed the feat/role-controller-signals branch 2 times, most recently from 84b1a3d to 96d83fc Compare November 3, 2026 11:40
Reads each node's probe entry and the Nodes, and reports per host whether it is fit, unfit or
unknown with the numbers behind it, and the swap of an unfit voter for a fit standby that it
would make. It changes nothing in the cluster: its only write is its own status ConfigMap.

Every limit derives from the election timeout, the tail percentile or a named fact about etcd
or k3s. The unit tests hold each to its boundary, and the memory limit to the regrade core's
own is_over.
…lth for the controller

A per-node probe appends one sample per period to its key of a shared ConfigMap and keeps the
last window of them: the TCP connect time to every voter's peer port, what a voter's etcd
metrics gained since the last sample, an fdatasync reading on a node that is not a voter, the
memory signals the regrade core reads, and whether the default route leaves by wired, Wi-Fi or
an interface whose link cannot be seen.

The tests read the link from a sysfs tree whose links loop as the kernel's do and from the
host's own sysfs where there is one, the connect times from real listeners, etcd's metrics from
pages a real three-member cluster served (and a live local cluster when an etcd binary is
available), and the disk reading from the real fio.
One image holds the controller, which its entrypoint runs on an interval, and the probe, which the
DaemonSet starts by name. It carries fio, the disk check etcd's own documentation uses, and the
regrade core whose functions the probe reads memory with. The unit job installs fio so that the
probe's fdatasync reading is tested with the real tool.
…n by hand

`fleet-role-probe.sh check link|connect|memory|etcd|fsync` prints one reading and exits without
touching the cluster, so that every probe can be run on a real host and its output set beside a
reading taken there by hand.
A fleet-role Deployment and a fleet-role-probe DaemonSet in the platform namespace, each with
a ServiceAccount that can get and list nodes and reach only its own ConfigMaps by name, and
neither able to change a node. The controller's settings follow the cluster role's own where
that role defines them, with fallbacks the tests hold to its defaults, and the target is
refused unless it is odd, at least three and a place below the maximum number of servers.
…toscaler's

The heartbeat reads the controller's status ConfigMap, and is allowed that one by name, only when
the role controller is installed, and publishes it to the gist while the fleet is vouched for,
on the same terms as the autoscaler's status.
…he controller

The role works out the cluster's election timeout once and gives the same figure to the controller
and the probe, so neither script holds a default of its own that could differ from the other.
A probe on any other node recorded a window of samples nobody reads, and the shared ConfigMap,
which holds at most a mebibyte, would have filled with the size of the fleet. Such a node now
records nothing and loses an entry it had, so the ConfigMap grows with the hosts that decide the
control plane.
…to act

A voter that is paused or unreachable stops reporting while its last sample still looks healthy,
so the gate on etcd's state read stale gauges. It now says the probe has not reported, as the
reason nothing would change.
Linux limits one argument to a process to 128 KiB, and the entries of a few voters together pass
it, so the controller failed with an argument list too long on a runner while it passed on
macOS. The tests now run with a jq that refuses such an argument, and hold both scripts to it
with inputs the size of a full ConfigMap.
…runs

The role controller's probe runs from its own image, whose Dockerfile contents are held by the
role controller's tests instead.
…er_role_controller

The opt-in is the cluster role's variable, off by default, which the ARC role takes as the default
of its own switch, so one inventory setting turns on the probe and the controller for the group.
The tests hold that switch and the names the status is published under.
…on pull requests

A build-only job builds fleet-role/Dockerfile as the release does and runs what the pods run from
the image as built. The unit job installs the etcd v3.6.15 release, checked against the SHA-256
its SHA256SUMS asset publishes before it is extracted, for the probe's live test.
…the voter it replaces

A slow link makes both its ends unfit, so ranking unfit voters alone could send a healthy peer
out in place of the one on the poor path, and a standby that measures the slow voter slow was
never fit. The voter on the most slow links is now the one to replace, nothing is swapped when
it is pinned, and a candidate is judged against the voters that would remain.
…o it is up

etcd keeps one gauge per peer that counts its active streams, up to two, so a follower with both
streams to the leader and one to the other follower read as connected to one peer and the gate
held every swap back. The fixture is a page a follower of a real three-server k3s cluster served.
…refuses one

A probe on a poor path made four sequential calls a sample and went stale, and its process exited on
the first refusal and came back as a restart. It now reads its own entry once, reads the nodes once
a sample, and a patch the API will not take is tried again next period with the first-sample restart
mark kept until a sample is recorded.
… a paused one

A mesh scenario with three servers and a standby agent runs the probe and controller from the
image built from the checkout, checks their permissions, waits for a full window of real samples
with every host fit, puts tc netem on a voter's mesh interface and checks the swap is named,
pauses a voter and checks the controller says it would not act, and that nothing in the cluster
changed. etcd's data is on memory so that the machine's disk does not decide the verdict.
…or the role controller

While github_runner_cluster_role_controller is on, the role refuses a voter count that is even,
under three or without a place of headroom below the most servers allowed, a datastore with no
etcd, and fewer eligible hosts than the target once any host is marked. With none marked the
controller only reports. The settings default off, to three voters and to no host eligible or
pinned.
…bels

Every runtime passes the two labels to k3s as --node-label: a Docker server or agent through the
entrypoint, and a native node through its configuration. Both labels are written with true or false
on every host that uses the controller, since a kubelet never removes a label and a setting turned
off is written over. A host that uses none of it gets no label and no extra Compose file, so no
container is recreated by an upgrade. The ARC role's controller takes its target and label names
from these settings.
…ntory

The role_controller mesh scenario marks every host eligible and pins the first in its inventory, as
an owner would, in place of labelling the nodes with kubectl, and checks that the labels reach the
nodes of the servers and of the agent and that the controller reads them.
@Mearman
Mearman force-pushed the feat/role-controller-signals branch from 7a67b0c to 93b6340 Compare November 3, 2026 12:55
@Mearman
Mearman marked this pull request as ready for review November 3, 2026 13:20
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ⚠️ Failed 2026-11-03T13:20:08.054076Z
We couldn’t complete this review. Comment @codex security review to try again.
93b6340 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has exhausted its Codex allowance and has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@Mearman
Mearman merged commit e7accd7 into main Nov 3, 2026
39 checks passed
@Mearman
Mearman deleted the feat/role-controller-signals branch November 3, 2026 13:20
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 2.26.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant