Skip to content

feat(cluster): switch a Docker host's role from the same actuator - #217

Merged
Mearman merged 12 commits into
mainfrom
feat/docker-role-actuator
Nov 4, 2026
Merged

Mearman merged 12 commits into
mainfrom
feat/docker-role-actuator

Conversation

@Mearman

@Mearman Mearman commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Refs #183

Gives the role actuator a Docker platform (files/role/fleet-role-actuator-docker.sh), so the role controller's voter swap can move a Docker host between its k3s and k3s-agent services the way it moves a native host between its units. On a server or eligible Docker host with the controller on, the role runs it as the role-actuator service of the host's Compose project. The service mounts the Docker socket, the project directory at its own absolute path, and the server's k3s-data volume where the server's container mounts it.

  • promote writes fleet-role.d/role and join-url and stops k3s-agent. It then copies the node password and Tailscale state from the stopped agent's volumes into the server's (<project>_k3s-agent-node to <project>_k3s-node, <project>_k3s-agent-tailscale-data to <project>_k3s-tailscale-data) and starts k3s.
  • step-down restarts the server at etcd-only through fleet-regrade-docker.sh (write_level, restart_into), then writes the role file and stops the regrade container.
  • finish-demote and rollback stop the server, then in k3s-data move server/db aside and drop the level file. They then remove the join URL, copy the identity back and start k3s-agent.
  • snapshot runs k3s etcd-snapshot save in the server's container.

The identity copy is files/node-identity.sh, which is the node password seeding pipe from tasks/node_password.yml generalised: docker cp out of a stopped container straight into a throwaway container that is never started. node_password.yml now runs that script too. The regrade wrapper idles while the role file names another role than the server.

Tests

New unit tests in tests/unit/test_role_actuator.py, against a new Docker stand-in (tests/role_actuator/fake-docker.py). The stand-in keeps containers and volumes as files and moves a real tar stream between its docker cp processes. The tests cover:

  • promotion and finished demotion, with the identity in the right volumes;
  • no copy out of a running container or into a volume a running container mounts;
  • no two node containers running at once;
  • volume names and labels derived from the project;
  • the node password, Tailscale state and actuator token kept out of every call and all output;
  • a failed server start bringing the agent back;
  • step-down at etcd-only with regrade stopped;
  • a demotion not finished while the member stays;
  • kubectl running in the node container that is up, with the kubeconfig on stdin;
  • the snapshot;
  • node-identity's refusals and its clean-up after a failed copy;
  • the regrade wrapper idling, refusing a command by hand, and running as before on a server;
  • the role placing every script the Docker actuator sources, and the service's mounts against the settings.

The role_actuator mesh scenario gains a Docker part. It demotes the third Docker server by hand-set annotations and checks:

  • the agent has the same password hash and Tailscale IP;
  • there is exactly one Node;
  • db.demoted is in k3s-data;
  • the agent is Ready on a heartbeat after its start.

It then promotes the server again and checks it becomes a voter in the same etcd cluster ID.

I checked that each new test discriminates. In a throwaway clone I made each break below, saw the named test fail, then saw it pass on this branch:

Break Test that failed
Identity not copied on promotion test_a_promotion_moves_the_identity_to_the_server_and_starts_it_in_place_of_the_agent
Identity not copied back on demotion test_a_demotion_is_finished_by_moving_the_identity_back_and_starting_the_agent
--stopped check removed from node-identity test_a_copy_out_of_a_running_container_is_refused_with_nothing_changed
Volume created without its Compose volume label test_the_identity_volumes_a_promotion_creates_are_named_and_labelled_for_the_project
A failed copy keeps the volume it created test_a_failed_copy_removes_the_volume_it_created_and_its_throwaway_container
Kubeconfig passed as a file path rather than on stdin test_kubectl_runs_in_the_node_container_with_tailscale_up_and_takes_the_kubeconfig_on_its_standard_input
Tailscale socket probe dropped from node_kubectl (the test asserts the probe call) same test
Agent not brought back after a failed server start test_a_promotion_whose_server_does_not_come_up_brings_the_agent_back_with_its_identity
Datastore not moved aside test_a_demotion_is_finished_by_moving_the_identity_back_and_starting_the_agent
Regrade container not stopped on step-down test_a_step_down_restarts_the_server_at_etcd_only_then_names_the_agent_and_stops_regrading
Step-down without the etcd-only level and restart same test
Regrade wrapper not idling on an agent's role file test_the_watcher_idles_on_a_host_whose_role_file_names_the_agent
Project directory not mounted at its own path test_the_service_mounts_the_socket_the_project_at_its_own_path_and_the_servers_data_where_the_settings_say
node-identity.sh not placed beside the actuator test_the_role_places_every_script_the_docker_actuator_runs
Credential applied with no wait for the other server's API test_the_credential_is_applied_once_the_other_servers_api_serves
ConfigMap create tolerating only the AlreadyExists reason, which kubectl create configmap never prints test_a_report_configmap_another_host_created_first_is_taken_as_it_is
ConfigMap create tolerating every failure test_any_other_failure_to_create_the_report_configmap_fails_the_run
Credential applied client-side (no --server-side) test_an_eligible_host_runs_the_watcher_with_its_own_credential_and_settings
flock not installed in the image (BusyBox's takes no -w) test_the_image_installs_the_flock_the_switch_lock_waits_with
Watch not retaking a finish whose report the API lost, nothing of the server left test_a_finish_whose_report_the_api_did_not_take_is_reported_again_though_nothing_of_the_server_is_left and test_a_report_the_api_refused_to_take_is_made_again_once_it_takes_it_and_the_entry_ends_at_the_finish
A step-down not counted as taken once its finish is reported test_a_finish_reported_in_every_phase_is_not_replaced_by_the_step_down_it_follows

Deviations from the plan

  • The service lives in its own templated Compose file (docker-compose.role-actuator.yml), not in docker-compose.yml. Only the role's Compose command for this service names that file. Two reasons: the project directory has to be mounted at its own absolute path, which only the role knows, and a variable in docker-compose.yml would be interpolated for every service on every host.
  • The image has no kubectl. The actuator's container is on no tailnet, and the API servers are reached by their mesh names, so kubectl runs in whichever node container is up with Tailscale running. The actuator's kubeconfig goes in on kubectl's standard input (--kubeconfig /dev/stdin), so it never sits on that container's disk and never appears on a command line. The image instead gains docker-cli-buildx, which Compose needs to build the other service's image the first time a host runs it.
  • A server container that does not come up brings the agent back at once. The role file names the agent again and the join URL is removed, and the step is reported failed. Without this, a Docker host with neither node container up could not read the Lease that a rollback asks for. A later rollback finds the agent running and changes nothing.
  • The actuator mounts the server's k3s-data volume, so the datastore and level file are handled as files rather than through a throwaway container.
  • node-identity.sh replaces each file the source holds and leaves any other file in an existing destination volume. For a node password and a Tailscale state, those files are the whole identity.
  • The throwaway container of the password seeding is now named <volume>-identity-copy. The node password tests were updated to match.
  • On a Docker host, "provisioned to be a server" means its .env holds the server's TLS names. env.j2 writes both services' variables.
  • docker-compose.fleet-role.yml also mounts the role directory into regrade. As a result, a host that already uses that override recreates its regrade container once.
  • The credential tasks moved into role_actuator_api.yml and role_actuator_credential.yml, shared by both platforms.
  • The mesh demotes a Docker server from three voters to two, since the scenario has no fourth Docker voter. It then promotes the server back into the same cluster ID.
  • The mesh helpers node_is_voter, member_removed and node_ready_since take a node and default to the native host.
  • The report ConfigMap is created by every host whose watcher runs, in the same play. With D1 only the native hosts did this. A refusal saying the ConfigMap already exists passes, and any other failure still fails the run. The old check looked for AlreadyExists, which kubectl create configmap does not print (it says error: failed to create configmap: configmaps "<name>" already exists). The native kubectl stand-in now answers in kubectl's own words.
  • The credential is applied only once the chosen server's /readyz serves. On a first run the Docker servers start together, so the server may not have an API or a kubeconfig yet.
  • The actuator's credential is applied server-side, with field manager fleet-role-actuator. Every host whose watcher runs applies the same objects in one play. A client-side apply reads each object before creating it, so two hosts that both see it missing would both create it, and one would be refused with AlreadyExists. A server-side apply creates or updates each object in one request. As a result, the role_controller mesh scenario now applies the platform namespace, since the role has already made it.
  • The image installs util-linux's flock. BusyBox's, the one Alpine has by default, takes no -w, so every step on a Docker host failed on its switch lock before it changed anything.
  • The watch retakes a finish whose report was lost. On a Docker host the node container that carries kubectl is the agent just started, which has no route to the API for a while, so the finish's report was lost after the finish had completed locally. The watch now takes the finish while the role file names the agent, it is not reported done and the Node still carries the swap's step-down, even when nothing of the server is left; it then only reports, and member_gone still decides. A step-down counts as taken once the finish of the same swap is reported in any phase, since the finish's report replaces the step-down's. The promote and step-down reports were already retried by the settled guard, since both steps re-run idempotently; a rollback gets no finish.
  • The mesh failure dump also shows each role-actuator container's state and log and the fleet-role-actuators data.

…mes with one script

The node password seeding pipe moves into files/node-identity.sh,
which copies a directory of a running or stopped node container into a named volume
through a throwaway container that is never started,
so the role actuator can move a Docker node's password and Tailscale state
between its server and agent services the same way.
With --stopped it refuses while either side could change the files.
…les any platform can include

Choosing the server whose API applies the credential, and applying it,
move out of the native watcher's tasks into role_actuator_api.yml
and role_actuator_credential.yml, so a Docker host's watcher
can be given the same credential the same way.
The role actuator gains a Docker platform,
so the role controller's voter swap can move a Docker host
between its k3s and k3s-agent services as it moves a native host
between its units.
On a server or eligible Docker host with the controller on,
the role runs it as the role-actuator service of the host's Compose project,
from its own Compose file, with the Docker socket,
the project directory at its own path and the server's k3s-data volume.

A switch writes the role directory the entrypoint reads,
stops one service, copies the node password and the Tailscale state
from its stopped container into the other service's volumes
through node-identity.sh, and starts the other service,
so the node keeps its name, password and tailnet address.
A step-down restarts the server at etcd-only through regrading's Docker actuator.
kubectl runs in whichever node container is up,
since the actuator's own container is on no tailnet,
and a server that does not come up brings the agent back at once,
so the host never loses its way to the API.
The regrade service now sees the role directory
and idles while it names another role than the server.
…ote it again

The role_actuator scenario's Docker part demotes the third Docker server
by the Node's annotations set by hand, checks its agent keeps the server's
node password and Tailscale address as one Node with the datastore moved aside
in k3s-data, then promotes it again into the same etcd cluster.
The scenario's node helpers now take the node to read.
…ver's API serves

On a first run the Docker servers start together,
and the server the credential is applied through may have no API
and no kubeconfig yet, so kubectl fell back to localhost and failed.
The role now reads that server's /readyz until it serves,
for as long as it waits for a server to be Ready, before applying.
… in use

Its service and its settings named the whole Compose file list,
which every Compose command of the role is to take
from github_runner_cluster_compose_files_in_use instead.
… another host created it

Every host whose watcher runs creates the ConfigMap in the same play,
and kubectl create configmap words the server's refusal as
`configmaps "<name>" already exists` without the AlreadyExists reason
the task looked for, so every host but the first failed.
The refusal is now told by that text naming this ConfigMap;
any other failure still fails the run.
The kubectl stand-in answers in kubectl's own words.
Every host whose watcher runs applies the same objects in one play.
A client-side apply reads each object and then creates it,
so two hosts that both find it absent both create it and one is refused;
a server-side apply creates or updates it in one request.
The cluster role now makes the namespace for the role actuators' credential
while the controller is on, so the scenario's create of it was refused.
The actuator's switch lock waits with `flock -w`, which BusyBox's flock, the one Alpine has by
default, does not take. Every step on a Docker host therefore failed on its lock before it
changed anything, and reported "another role switch on this host held its lock" at once.
…cenario fails

A Docker host's role-actuator container and the fleet-role-actuators ConfigMap were absent from
the failure dump, so a step that failed or was refused looked the same as one never taken.
…on again when the API lost it

A finish-demote whose report the API did not take left nothing of the server behind (the server
stopped, its datastore moved aside), so the watch's guard, which looked for something of the server,
never took the finish again, and the controller waited for a report that was not coming. On a Docker
host the node container that carries kubectl is the agent just started, with no route to the API
yet, so that report was lost on every demotion.

The finish is now taken while the role file names the agent, it is not reported done, and either
something of the server is left or the Node still carries the swap's step-down. It is idempotent
and member_gone still decides whether the member is gone.

The finish's report replaces the step-down's, so a step-down counts as taken once the finish of the
same swap is reported in any phase. Without that the watch ran the step-down again over the
finish's report, and the two reports replaced each other on every pass.
@Mearman
Mearman marked this pull request as ready for review November 4, 2026 14:01
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

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 ✅ Completed 2026-11-04T14:06:46.045342Z c8a46fe 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.

@Mearman
Mearman merged commit 9c0a694 into main Nov 4, 2026
33 checks passed
@Mearman
Mearman deleted the feat/docker-role-actuator branch November 4, 2026 14:10
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 2.38.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