Skip to content

Commit 3c5e332

Browse files
committed
fix: apply k3s version bumps declaratively through site.yml
Re-running site.yml with a raised k3s_version replaced the on-disk binary but left agents and joined servers running the old runtime, because the install script skips the service start and the roles only restarted on a config change. A version-only bump was therefore not applied until the services were restarted by hand. Always restart the k3s service in the server and agent roles on a site.yml run, so the cluster reliably picks up a new config or runtime without any logic to detect whether the binary changed. Run the server play one host at a time and wait for every restarted server, including the first, to report a healthy API before continuing, so a rolling change never drops the etcd quorum. The joined-server readiness check now waits for the servers handled so far instead of the full set, which is correct under both serial and parallel runs. Keep the dedicated upgrade.yml integration test and add a second upgrade through site.yml, asserting the running version the kubelet reports rather than the on-disk binary, so the tests fail if the restart regresses. Document the declarative upgrade path in the README. Co-authored-by: Dominik Wombacher <dominik@wombacher.cc> Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent de30fcd commit 3c5e332

5 files changed

Lines changed: 128 additions & 8 deletions

File tree

.github/workflows/integration.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jobs:
1919
env:
2020
STARTING_K3S_VERSION: v1.34.3+k3s1
2121
UPGRADE_K3S_VERSION: v1.35.1+k3s1
22+
SITE_UPGRADE_K3S_VERSION: v1.35.6+k3s1
2223
# K3s requires privileged containers to run inside Docker and access to cgrougs.
2324
steps:
2425
- name: Set container OS based on service manager
@@ -138,6 +139,37 @@ jobs:
138139
- name: Verify K3s upgraded on Agent
139140
run: docker exec agent-node k3s --version | grep ${UPGRADE_K3S_VERSION}
140141

142+
# Second upgrade, this time re-running site.yml with a bumped version: it
143+
# must upgrade the running runtime on both the server and the agent, not
144+
# just replace the on-disk binary.
145+
- name: Modify the k3s_version in inventory for a site.yml upgrade
146+
run: |
147+
sed -i "s/k3s_version: .*/k3s_version: ${SITE_UPGRADE_K3S_VERSION}/" tests/${{ matrix.inventory }}.yml
148+
149+
- name: Run site Playbook to upgrade via a version bump
150+
run: ansible-playbook playbooks/site.yml -i tests/${{ matrix.inventory }}.yml
151+
152+
# The install script replaces the on-disk binary regardless of a restart,
153+
# so assert the running version the kubelet reports to the API: it only
154+
# advances once the service was actually restarted onto the new build.
155+
# This is what fails if the version-bump restart regresses.
156+
- name: Verify the running K3s version upgraded on Server and Agent
157+
run: |
158+
for node in server-node agent-node; do
159+
for attempt in 1 2 3 4 5 6; do
160+
running=$(docker exec server-node k3s kubectl get node "$node" -o jsonpath='{.status.nodeInfo.kubeletVersion}' 2>&1)
161+
echo "$node running version: $running"
162+
if echo "$running" | grep -q "${SITE_UPGRADE_K3S_VERSION}"; then
163+
break
164+
fi
165+
if [ "$attempt" -eq 6 ]; then
166+
echo "$node did not reach ${SITE_UPGRADE_K3S_VERSION}"
167+
exit 1
168+
fi
169+
sleep 10
170+
done
171+
done
172+
141173
- name: Wait for all deployments to be ready
142174
run: |
143175
for attempt in 1 2 3 4 5 6; do
@@ -324,6 +356,50 @@ jobs:
324356
done
325357
exit 1
326358
359+
# #485: re-running site.yml with a bumped version must upgrade every etcd
360+
# server, restarting them one at a time so the quorum is never lost.
361+
- name: Modify the k3s_version in inventory for a site.yml upgrade
362+
run: |
363+
sed -i 's/k3s_version: v1.34.1+k3s1/k3s_version: v1.34.3+k3s1/' tests/ha.yml
364+
365+
- name: Run site Playbook to upgrade the servers via a version bump
366+
run: ansible-playbook playbooks/site.yml -i tests/ha.yml
367+
368+
# Assert the running (kubelet-reported) version, not the on-disk binary:
369+
# without a rolling restart the joined servers stay on the old runtime.
370+
- name: Verify the running K3s version upgraded on all servers via site.yml
371+
run: |
372+
docker exec server-node1 k3s kubectl get nodes -o wide
373+
for SERVER in $SERVERS; do
374+
for attempt in 1 2 3 4 5 6; do
375+
running=$(docker exec server-node1 k3s kubectl get node "$SERVER" -o jsonpath='{.status.nodeInfo.kubeletVersion}' 2>&1)
376+
echo "$SERVER running version: $running"
377+
if echo "$running" | grep -q v1.34.3; then
378+
break
379+
fi
380+
if [ "$attempt" -eq 6 ]; then
381+
echo "$SERVER did not reach v1.34.3"
382+
exit 1
383+
fi
384+
sleep 10
385+
done
386+
done
387+
388+
- name: Wait for all deployments to be ready after the site.yml upgrade
389+
run: |
390+
for attempt in 1 2 3 4 5 6; do
391+
echo "Attempt $attempt: checking deployments"
392+
output=$(docker exec server-node1 k3s kubectl get deployments -n kube-system -o jsonpath='{range .items[*]}{.metadata.name}={.status.readyReplicas}/{.spec.replicas}{"\n"}{end}' 2>&1)
393+
echo "$output"
394+
if ! echo "$output" | grep -q "<no value>" && echo "$output" | awk -F '[=/]' '{if ($2 != $3) exit 1}' ; then
395+
exit 0
396+
fi
397+
if [ "$attempt" -lt 6 ]; then
398+
sleep 15
399+
fi
400+
done
401+
exit 1
402+
327403
- name: Debug nodes/pods on failure
328404
if: failure()
329405
run: |

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ ansible-playbook k3s.orchestration.upgrade -i inventory.yml
161161
ansible-playbook playbooks/upgrade.yml -i inventory.yml
162162
```
163163

164+
Re-running the `site.yml` playbook after bumping `k3s_version` performs the same upgrade declaratively: it restarts the k3s services so the cluster picks up the new runtime, with servers restarted one at a time to keep the etcd quorum intact. The dedicated `upgrade.yml` playbook remains available and unchanged.
165+
164166
## Airgap Install
165167

166168
Airgap installation is supported via the `airgap_dir` variable. This variable should be set to the path of a directory containing the K3s binary and images. The release artifacts can be downloaded from the [K3s Releases](https://github.com/k3s-io/k3s/releases). You must download the appropriate images for you architecture (any of the compression formats will work). Additionally, you must run the `airgap` role to set up the airgapped environment.

playbooks/site.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
- role: airgap
99
- role: raspberrypi
1010

11+
# Servers are handled one at a time so a rolling change (config edit or version
12+
# bump re-applied through this playbook) never restarts more than one server at
13+
# once and cannot break the etcd quorum. Agents have no such limitation.
1114
- name: Setup K3S server
1215
hosts: server
1316
gather_facts: false
1417
become: true
18+
serial: 1
1519
roles:
1620
- role: k3s_server
1721

roles/k3s_agent/tasks/main.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,12 @@
9595
loop:
9696
- "K3S_TOKEN={{ token }}"
9797

98+
# Always restart so a re-run of site.yml — whether it changed the agent config
99+
# or bumped k3s_version — reliably brings the agent onto the new state. The
100+
# install script leaves the service stopped (INSTALL_K3S_SKIP_START), so a plain
101+
# restart also covers the first install.
98102
- name: Enable and start K3s agent
99103
ansible.builtin.service:
100104
name: k3s-agent
101-
state: "{{ 'restarted' if _agent_config_result.changed else 'started' }}"
105+
state: restarted
102106
enabled: true

roles/k3s_server/tasks/main.yml

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@
175175
state: restarted
176176
enabled: true
177177

178+
# Hold the play here until the first server is actually healthy. Under
179+
# serial: 1 this keeps the rolling guarantee symmetric: the play does not
180+
# advance to the next server while this one is only listening but not ready.
181+
- name: Wait for the first server API to report healthy
182+
when: not ansible_check_mode
183+
ansible.builtin.command:
184+
cmd: k3s kubectl get --raw=/healthz
185+
register: k3s_server_first_healthz
186+
until: k3s_server_first_healthz.rc == 0 and k3s_server_first_healthz.stdout == 'ok'
187+
retries: 30
188+
delay: 5
189+
changed_when: false
190+
178191
- name: Pause to allow first server startup
179192
when: (groups[server_group] | length) > 1
180193
ansible.builtin.pause:
@@ -294,14 +307,30 @@
294307
ansible.builtin.systemd:
295308
daemon_reload: true
296309

297-
- name: Restart K3s service
310+
# Always restart a joined server so a re-run of site.yml — whether it changed
311+
# config or bumped the version — reliably brings it onto the new state, the
312+
# same way the first server above always restarts. The play runs with
313+
# serial: 1, so this happens one server at a time; the health check below
314+
# blocks the next server until etcd quorum is back.
315+
- name: Restart K3s and wait for it to be healthy
298316
when:
299317
- ansible_facts.services['k3s.service'] is defined
300318
- ansible_facts.services['k3s.service'].state == 'running'
301-
- k3s_server_config_result.changed
302-
ansible.builtin.service:
303-
name: k3s
304-
state: restarted
319+
block:
320+
- name: Restart K3s service
321+
ansible.builtin.service:
322+
name: k3s
323+
state: restarted
324+
325+
- name: Wait for the restarted server API to report healthy
326+
when: not ansible_check_mode
327+
ansible.builtin.command:
328+
cmd: k3s kubectl get --raw=/healthz
329+
register: k3s_server_healthz
330+
until: k3s_server_healthz.rc == 0 and k3s_server_healthz.stdout == 'ok'
331+
retries: 30
332+
delay: 5
333+
changed_when: false
305334

306335
- name: Enable and start K3s service
307336
when: ansible_facts.services['k3s.service'] is not defined or ansible_facts.services['k3s.service'].state != 'running'
@@ -310,13 +339,18 @@
310339
state: started
311340
enabled: true
312341

313-
- name: Verify that all server nodes joined
342+
# Under serial: 1 the servers come up one at a time, so wait for the count
343+
# joined so far (this host's position in the play) rather than the full set.
344+
# This condition is also satisfied when the play runs without serial.
345+
- name: Verify that this and previously handled server nodes joined
314346
when: not ansible_check_mode and (groups[server_group] | length) > 1
347+
vars:
348+
_k3s_expected_servers: "{{ ansible_play_hosts_all.index(inventory_hostname) + 1 }}"
315349
ansible.builtin.command:
316350
cmd: >
317351
k3s kubectl get nodes -l "node-role.kubernetes.io/control-plane=true" -o=jsonpath="{.items[*].metadata.name}"
318352
register: k3s_server_nodes
319-
until: k3s_server_nodes.rc == 0 and (k3s_server_nodes.stdout.split() | length) == (groups[server_group] | length)
353+
until: k3s_server_nodes.rc == 0 and (k3s_server_nodes.stdout.split() | length) >= (_k3s_expected_servers | int)
320354
retries: 20
321355
delay: 10
322356
changed_when: false

0 commit comments

Comments
 (0)