Skip to content

feat(cluster): pin the k3s and tailscale images every node is built from - #80

Draft
Mearman wants to merge 6 commits into
mainfrom
feat/pin-cluster-versions
Draft

Mearman wants to merge 6 commits into
mainfrom
feat/pin-cluster-versions

Conversation

@Mearman

@Mearman Mearman commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Every node's k3s image was built from rancher/k3s:latest and tailscale/tailscale:latest. Compose builds the image only when it is missing, so a node first built, rebuilt or moved on a later day joined the running cluster on whatever version was newest then, possibly another k3s minor than the servers it joined.

github_runner_cluster_k3s_version (default github_runner_cluster_k3s_tested_version, v1.34.1-k3s1) and the new github_runner_cluster_tailscale_version (default v1.102.4) are now pinned tags. The role writes both into .env, the Compose file refuses to build without them (${VAR:?}), and the Dockerfile has no default for either ARG. bootstrap.sh no longer turns an empty K3S_VERSION into latest; it passes either variable through only when set.

Before it changes anything on a host the role fails:

  • if either value is not a pinned tag;
  • if the hosts of the cluster group do not all name the same k3s minor version. Every host is read through hostvars, and one that sets nothing counts as the tested version, so the check gives the same answer on every host, the odd one included, and under --limit;
  • if the k3s version breaks k3s's upgrade path or Kubernetes' version skew policy: this node built older than it runs (by patch release and k3s build too), any node more than one minor above another, a server an older minor than any node runs, or an agent a newer minor than any server runs. The agent rule applies only when the run builds the agent's image, so the documented upgrade (new pin everywhere, then servers, then agents) passes; a test follows it end to end. What runs is read from k3s --version in this node's running container, and every node's kubelet version read through each server of the group in the inventory, each on its own host with its Docker context, so one server stopped or being rebuilt does not hide the others; each server's whole read, its Docker daemon and docker exec included, is cut off after github_runner_cluster_cluster_read_timeout seconds (a positive whole number) and the server named, so one that hangs does not hold up every host. When servers run but none can be read the role refuses, saying why for each; when none runs it compares only the node's own container and says why: a first bootstrap, a group that joins a cluster outside the inventory (a bootstrap.sh joining server), or no server in the inventory (a bootstrap.sh agent). github_runner_cluster_allow_k3s_version_jump=true skips the comparison for one run.

This is a breaking change, and the three feat(cluster)! commits carry BREAKING CHANGE footers saying what to set, so the release is a major one: latest is refused, a cluster that floated to a newer minor than the default is refused until github_runner_cluster_k3s_version names what it runs, and a Compose file path with a colon is refused. The role README has a section on upgrading from a release before the pins.

The node's .env also names the Compose files, as absolute paths, and the project the role runs it with (COMPOSE_FILE, COMPOSE_PROJECT_NAME), so a docker compose command run by hand in the node's directory, or with --project-directory from another, uses the same files (including the headscale_in_cluster override and any extra Compose file) and project. The role refuses a Compose file path with a colon, which COMPOSE_FILE cannot carry.

Changing a pin changes no running node: Compose leaves build arguments out of a container's configuration hash, so the role rewrites .env and keeps the container. The role README's Versions section describes the checks and an upgrade by hand: --profile server and the host's Docker context, servers one at a time and then agents, each cordoned and drained once its runner pods finish (servers run runner pods too), with etcd checked healthy on every server before and after each restart.

tests/unit/test_cluster_versions.py covers the filters, the Dockerfile's arguments and bases, each node service's build arguments, a docker compose config render with and without the versions, both .env templates, a hand-run docker compose config picking up the role's files and project from .env, bootstrap.sh's mapping of empty and set versions, and the checks run through the role itself (so its defaults load below the inventory, as in a real run): a host on another minor with the others unset, alone and under --limit, and the running-version reads against a stand-in docker: servers and agents reading through every server, a stopped bootstrap server beside a newer cluster, a bootstrap server whose API fails, a group with no bootstrap host, the inventory bootstrap.sh builds (through the real topology tasks) for an agent and a joining server, a daemon that cannot be reached on this or another host, --check, and the override skipping every read. Each check was mutation-tested by breaking the code it covers.

The CI unit job now runs every test playbook with Ubuntu's ansible-core, the one the filters import (it could pick up another Ansible earlier on PATH), and prints both versions; the version tests also pass under ansible-core 2.16.3, where a task timeout is reported only in its message.

@Mearman

Mearman commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 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-10-13T16:04:26.507396Z 93255a7 Manual request
ℹ️ 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

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: b5e132b416

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@Mearman

Mearman commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 93255a7e04

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@Mearman
Mearman force-pushed the feat/pin-cluster-versions branch from 93255a7 to ca5df0f Compare October 13, 2026 16:33
@Mearman

Mearman commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

@codex security review

@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 commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

@codex security review

@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 force-pushed the feat/pin-cluster-versions branch from b728aec to c2b9c25 Compare October 13, 2026 20:28
…from

A node's image was built from rancher/k3s:latest and tailscale/tailscale:latest,
so whichever version was newest on the day a node was first built or rebuilt
joined the running cluster unannounced.
github_runner_cluster_k3s_version now defaults to github_runner_cluster_k3s_tested_version
and the new github_runner_cluster_tailscale_version to the tested Tailscale release.
The role writes both into .env, the Compose file refuses to build without them
and the Dockerfile has no default for either. Before it changes anything on a
host the role fails if either is not a pinned tag, or if the hosts of the group
do not all name the same k3s minor version; a host that sets none counts as the
default, so the check gives the same answer on every host and under --limit.
bootstrap.sh passes the versions through only when set.

BREAKING CHANGE: latest is no longer accepted for github_runner_cluster_k3s_version
or K3S_VERSION in .env.bootstrap. Set the rancher/k3s tag the cluster runs
(k3s --version in a node's container, with + written as -), once for the whole
group, or leave it unset for the default; set github_runner_cluster_tailscale_version
if the nodes run another Tailscale. The next run rewrites each node's .env and
recreates nothing.
…at runs

Before it changes anything on a host, the role reads k3s --version in the node's
running container and every node's kubelet version and role through each server
of the group in the inventory, each on its own host with its own Docker context,
so one server stopped or being rebuilt does not hide what the others run. Each
server's read is cut off after github_runner_cluster_cluster_read_timeout seconds,
a positive whole number, so a hung Docker daemon or API server is named instead of
holding up every host's run.

It refuses what k3s's upgrade path and Kubernetes' version skew policy rule out:
this node built older than it runs (by patch release and k3s build too), any node
more than one minor above another, a server an older minor than any node runs, and
an agent a newer minor than any server runs. The last applies only when the run
builds the agent's image: changing the pin builds nothing, so the documented
upgrade, which writes the new pin everywhere before the servers are rebuilt, passes.
When servers run but none can be read it refuses and says why for each; when none
runs it compares only the node's own container and says why: a first bootstrap,
a group that joins a cluster outside the inventory, or one with no server in it.
github_runner_cluster_allow_k3s_version_jump skips the comparison for one run.

BREAKING CHANGE: a cluster that runs a newer k3s than the version the role is
given, such as one built from latest before the pins, is refused until
github_runner_cluster_k3s_version names the tag it runs. A deliberate downgrade,
a cluster being replaced or one that cannot be read needs
-e github_runner_cluster_allow_k3s_version_jump=true for that run.
A docker compose command run by hand in the node's directory used only
docker-compose.yml and a project named after the directory, so it dropped the
headscale_in_cluster override and any extra Compose file the role runs the node with.
The role now writes COMPOSE_FILE, as absolute paths, and COMPOSE_PROJECT_NAME into
.env, so such a command works in the directory and with --project-directory from
another, and fails before touching the host when a Compose file path holds a colon.

BREAKING CHANGE: a github_runner_cluster_dir or github_runner_cluster_compose_files
path with a colon in it is refused, since COMPOSE_FILE separates paths with colons.
Move the file or directory to a path without one.
… import

The unit job imports the filter plugins with Ubuntu's ansible-core but ran
ansible-playbook from whatever came first on PATH, which on the runner image can
be another Ansible, so a test could pass on a version the role is never checked
with. The step now puts /usr/bin first, points ANSIBLE_PLAYBOOK at Ubuntu's and
prints both versions.
The role README gains a Versions section: what the pins and the checks refuse,
how to move a cluster from a release before the pins, and an upgrade by hand
through the Compose files and project the node's .env names, with --profile
and the host's pinned Docker context, servers then agents one at a time,
each cordoned and drained, with etcd checked healthy on every server.
@Mearman
Mearman force-pushed the feat/pin-cluster-versions branch from c2b9c25 to 3c9fe72 Compare October 13, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant