Repository navigation
fix(cluster): no second cluster from a former member, and no restart of a retired server - #211
Merged
Merged
Conversation
…de services docker-compose.fleet-role.yml bind-mounts fleet-role.d from the Compose project directory at /etc/github-runner in the k3s and k3s-agent services, where the entrypoint reads the role the host runs and the server a promoted server joins. A directory, because a file replaced by a rename has a new inode that a bind mount of the file does not follow. The override is listed only for hosts that take part in the role controller, the same hosts that publish its node labels, so a fleet that does not use it keeps its Compose configuration and no container is recreated by an upgrade. The role creates the directory on every host before any Compose command, so Docker never creates it as root.
Before Tailscale starts, the node entrypoint now reads the role directory: a service whose role the role file does not name, and a server whose datastore holds k3s's tombstone, idle until the container is stopped instead of starting k3s. k3s writes the tombstone when its etcd member is removed and at its next start joins again as a new learner, so a removed server restarted by its restart policy would otherwise become a member again. The server joins join-url when it is set, else K3S_JOIN_SERVER_URL. With no server to join, a datastore moved aside by a demotion (server/db.demoted and no server/db) is refused with a message instead of bootstrapping a second cluster; a datastore k3s reuses, or none at all, keeps --cluster-init. The idle wait keeps the shell as PID 1 with a handler for SIGTERM, since an exec'd sleep would ignore docker stop's signal until its timeout ran out.
… starting fleet-role-is.sh takes the server directory for the server unit and exits 1, which systemd reads as a skipped start that Restart=always does not repeat, while server/db/etcd/tombstone exists. A server removed while its role file still named it a server, because it was down at the time, no longer rejoins as a learner. Moving server/db aside, as a demotion does, takes the tombstone with it.
The bootstrap Docker server's etcd member is removed through another server. Restarted, it must idle on its tombstone; with its datastore moved aside and no server to join, it must refuse to start; given a join-url naming another server, it must join the existing cluster as a learner that is promoted, with the same etcd cluster ID and node password; and a role file naming the agent must leave its server container running only the idle wait. The role_controller scenario's check run also covers the new override.
A second run of the role on a host whose datastore exists takes another path than the bootstrap, so the tombstone test runs the role once and runs the condition it wrote against each state of the datastore.
Mearman
force-pushed
the
feat/swap-guard-former-member
branch
from
November 3, 2026 22:32
707e333 to
aaca68f
Compare
Mearman
marked this pull request as ready for review
November 3, 2026 22:54
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
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. |
|
🎉 This PR is included in version 2.34.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR A of the decided plan in #183: a former member never starts a second cluster, and a server whose etcd member was removed never starts again by its own restart policy.
Refs #183
What changes
fleet-role.din each Docker host's Compose project directory, created empty by the role, is bind-mounted read-only at/etc/github-runnerinto bothk3sandk3s-agent. It holdsrole(serveroragent) andjoin-url, both absent until something writes them; an absentrolebehaves exactly as before.files/k3s/node-entrypoint.sh), run before Tailscale starts: a service whose role the file does not name idles, and so does a server whileserver/db/etcd/tombstoneexists. The join URL isjoin-urlwhen not empty, elseK3S_JOIN_SERVER_URL. With no join URL,server/dbpresent passes--cluster-initas before, neither present passes it (first bootstrap), andserver/db.demotedwithoutserver/dbexits 1 with a message and starts nothing.fleet-role-is.sh server ROLE_FILE SERVER_DIRalso exits 1 (start skipped) while the tombstone exists; the server unit passes its server directory.Decisions where the plan and the code disagreed
docker-compose.fleet-role.yml, not indocker-compose.yml. The role keeps a fleet that does not use the controller from being recreated by an upgrade (that is whydocker-compose.node-labels.ymlanddocker-compose.api-port.ymlexist). A new bind mount in the base file would recreate every Docker node once, agents included, cutting their running jobs. The override is listed for exactly the hosts that publish the controller's labels (github_runner_cluster_publishes_node_labels: controller on, or host eligible or pinned), which are the only hosts PR C and D2 write into the directory. A host outside that set gets the directory but no mount and no recreate.exec sleep infinity. The entrypoint is PID 1 and the kernel delivers PID 1 only signals it handles, so an exec'dsleepwould ignoredocker stop's SIGTERM and be killed only after the stop timeout. The shell keeps PID 1, traps TERM and INT to exit 0, and waits onsleep infinity &in a loop, so it still never exits by itself and stops at once when asked. The mesh scenario checks the stopped idle container's exit code is 0.rejoinmesh scenario removes the member first. Movingserver/dbaside on a server that is still a member and starting it with a join URL is refused by k3s itself:join()inpkg/etcd/etcd.go(v1.34.1+k3s1) returns "duplicate node name found" while a member with that node name exists. The scenario therefore removes node 1's member through node 2 (etcd.k3s.cattle.io/remove=true, as a demotion does), which also lets it check the tombstone guard against real k3s: k3s writes the tombstone and keeps running (pkg/daemons/executor/etcd.go), and the restarted container idles instead of rejoining.server/db.demoted, noserver/dband nothing to join is refused whateverK3S_DATASTOREsays, since a fresh SQLite datastore would be a second cluster as surely as--cluster-init.supervise_self_hosted), since its direct start would otherwise create a fresh datastore beside a moved-aside one.Tests
tests/unit/test_node_entrypoint.py(new): join URL precedence;--cluster-initwith a datastore, with one moved aside, and with none; idling for a role the file does not name (both services, and an empty file); idling on a tombstone whatever the role file says, the agent unaffected; no change with no role file or with the service's own role; the guards placed before Tailscale; the override's mount, its placement and directory creation, and the mount as Compose merges it.tests/unit/test_native_server.py: the server unit's condition, run from the unit the role writes, refuses on a tombstone and passes onceserver/dbis moved aside.join_urlthe wayset_join_urldoes;test_cluster_voters.pylists the new override for an eligible host and still none for a host that uses nothing.rejoin(added to the workflow matrix): removal, tombstone idle, the refusal with no join URL (and no k3s start since), the join throughjoin-urlas a learner that is promoted with the same etcd cluster ID, the same node password, and a role file namingagentleaving the server container running onlyshandsleep.role_controller's check-run step also deletes the new override.Each guard was broken on purpose with a scratch edit of the production file, the test that covers it was run and went red, then the file was restored byte for byte (
git diffempty) and the same test went green. Run locally against the rebased head withpython3 -m unittest <module>.<class>.<test>fromtests/unit.falsetest_node_entrypointtest_a_service_whose_role_the_role_file_does_not_name_idlesstartedprinted for server/agent, agent/server, server/emptyfalsetest_node_entrypointtest_a_server_whose_member_was_removed_idles_whatever_its_role_file_saysstarted ... --serverwith no role file and withserver--cluster-initkept withserver/dbpresent[ ! -e server/db ]dropped from the refusaltest_node_entrypointtest_a_server_with_its_datastore_and_nothing_to_join_keeps_cluster_initserver/db.demoted, noserver/db, no join URLfalsetest_node_entrypointtest_a_former_member_with_nothing_to_join_is_refused_and_k3s_is_not_started0 != 1: k3s arguments built--cluster-initon a first bootstrap (no datastore)[ -e server/db.demoted ]dropped from the refusaltest_node_entrypointtest_a_first_bootstrap_with_no_datastore_at_all_passes_cluster_init[ -z "$join_url" ]dropped from the refusaltest_node_entrypointtest_a_former_member_with_nothing_to_join_is_refused_and_k3s_is_not_startedK3S_JOIN_SERVER_URLand withjoin-urljoin-urltakes precedence overK3S_JOIN_SERVER_URLjoin_url=""test_node_entrypointtest_the_join_url_file_takes_precedence_over_the_inventorys_join_url--serveris the bootstrap URL, not node 2'sjoin_host_ofgivenK3S_JOIN_SERVER_URLagaintest_node_entrypointtest_the_guards_run_before_tailscale_starts_and_the_server_joins_the_url_they_chosejoin_host_of "$join_url"not found in the server casefleet-role-is.shrefuses on a tombstonefalsetest_native_servertest_the_server_unit_does_not_start_while_its_datastore_holds_a_tombstone0 != 1: the condition passed with a tombstone.../serverargument removed fromExecCondition=1 != 0: the condition fails before any tombstonek3sservice removed fromdocker-compose.fleet-role.ymltest_node_entrypointRoleDirectoryTest(override shape; merged bydocker compose config)k3sentry, no/etc/github-runnermount onk3sdocker-compose.fleet-role.ymlentry removed fromgithub_runner_cluster_compose_file_listtest_cluster_voterstest_an_eligible_host_...andtest_with_the_controller_on_...The mesh
rejoinscenario needs Docker and real k3s and runs only in CI, where it passed on this head; it covers the same guards against real k3s: the mount (does not mount ... read-only at /etc/github-runner), the tombstone idle (the log linetombstone says this server's etcd member was removed,node1_idles, two started members), the idle trap (the idle container did not exit 0 on docker stop's SIGTERM), the refusal (has no server to joinlogged, noStarting k3s vsince, restarted by policy, members and cluster ID unchanged), the join throughjoin-url(node1_voter_started,Promoted learner, same cluster ID and node password) and the role-mismatch idle (role names 'agent', not server, onlyshandsleep, no restart).