railiance-cluster/ansible/k3s-kubeconfig-mode.yml
codex 36f733c12c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
RCLUSTER-WP-0019: keep registries.yaml root 0600 (found world-readable with registry login)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 605711@bnt-lap001
Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61
2026-10-20 22:40:41 +02:00

323 lines
14 KiB
YAML

---
# Narrow existing-host change: k3s admin kubeconfig mode 644 -> 600.
# Owner workplan: RCLUSTER-WP-0019 (for CUST-WP-0073-T02).
#
# Why a separate playbook: bootstrap.yml installs k3s behind a `creates` guard,
# so rerunning it never changes the flags of an already-installed server, and
# a bare chmod on the kubeconfig is reverted when k3s restarts. This playbook
# changes only the persistent mode setting on an existing host.
#
# What it does (apply mode):
# 1. Refuses unless the k3s unit matches a pinned preimage SHA256 that carries
# exactly one `--write-kubeconfig-mode=644` flag, has no drop-ins, no
# pending daemon-reload, and no pre-existing /etc/rancher/k3s/config.yaml
# or config.yaml.d. Already-converged hosts are recognised and left alone.
# 2. Backs up the unit to /root/k3s-kubeconfig-mode-backup/ (root, 0600).
# 3. Rewrites only that flag to 600 (two bytes; result pinned by SHA256) and
# writes config.yaml with write-kubeconfig-mode "0600". The CLI flag wins
# over config.yaml, so both are set to the same value.
# 4. daemon-reload, restart k3s, wait for /readyz and node Ready, verify the
# kubeconfig is root:root 0600.
# Unit and kubeconfig contents are never printed (no_log / diff: false).
#
# Usage (attended; inventory is the operator's local ansible/hosts.ini):
# ansible-playbook ansible/k3s-kubeconfig-mode.yml -e target=<host> --check --diff
# ansible-playbook ansible/k3s-kubeconfig-mode.yml -e target=<host> --diff
#
# Rollback (restores the backed-up unit, removes config.yaml, restarts k3s):
# ansible-playbook ansible/k3s-kubeconfig-mode.yml -e target=<host> \
# -e k3s_kubeconfig_mode_action=rollback \
# -e k3s_unit_backup=/root/k3s-kubeconfig-mode-backup/k3s.service.<stamp>
# Manual rollback equivalent on the host:
# sudo install -o root -g root -m 0644 <backup> /etc/systemd/system/k3s.service
# sudo rm -f /etc/rancher/k3s/config.yaml
# sudo systemctl daemon-reload && sudo systemctl restart k3s
- name: K3s admin kubeconfig mode 0600 (existing host)
hosts: "{{ target | default('no_target_given') }}"
become: true
gather_facts: false
serial: 1
vars:
k3s_kubeconfig_mode_action: apply # apply | rollback
k3s_unit_path: /etc/systemd/system/k3s.service
k3s_config_path: /etc/rancher/k3s/config.yaml
k3s_config_dropin_dir: /etc/rancher/k3s/config.yaml.d
k3s_admin_kubeconfig: /etc/rancher/k3s/k3s.yaml
k3s_backup_dir: /root/k3s-kubeconfig-mode-backup
# Observed 2026-10-19 on railiance01 (the-custodian
# docs/changes/CUST-WP-0073/existing-k3s-host-r1). Override only after a
# fresh reviewed observation; drift is refused, never auto-rebased.
k3s_unit_preimage_sha256: b5398c385430ce225faadc1f3724597fe4b05dd999a04b8abbca64d4b0d68c0e
k3s_unit_candidate_sha256: 866ea58bfab00215e5bd3352287ebb0230d4ddb7dd3ed4b1cd853e74c6698d7d
k3s_config_content: |
# Managed by railiance-cluster ansible/k3s-kubeconfig-mode.yml (RCLUSTER-WP-0019)
write-kubeconfig-mode: "0600"
k3s_mode_flag_regex: '(--write-kubeconfig-mode(?:=|[ \t]+)["'']?)644\b'
tasks:
- name: Require a supported action
ansible.builtin.assert:
that: k3s_kubeconfig_mode_action in ['apply', 'rollback']
fail_msg: "k3s_kubeconfig_mode_action must be apply or rollback"
quiet: true
# ── Preflight (read-only, runs in check mode too) ────────────────────────
- name: Stat k3s unit (hash only, no content)
ansible.builtin.stat:
path: "{{ k3s_unit_path }}"
checksum_algorithm: sha256
follow: false
register: unit_stat
- name: Read k3s service state
ansible.builtin.command:
argv: [systemctl, show, k3s, --property=ActiveState, --property=DropInPaths, --property=NeedDaemonReload]
register: k3s_show
changed_when: false
check_mode: false
- name: Stat config.yaml and config.yaml.d
ansible.builtin.stat:
path: "{{ item }}"
follow: false
loop:
- "{{ k3s_config_path }}"
- "{{ k3s_config_dropin_dir }}"
register: config_stats
- name: Derive preflight facts
ansible.builtin.set_fact:
k3s_state:
ActiveState: "{{ k3s_show.stdout | regex_search('^ActiveState=(.*)$', '\\1', multiline=True) | first }}"
DropInPaths: "{{ k3s_show.stdout | regex_search('^DropInPaths=(.*)$', '\\1', multiline=True) | first }}"
NeedDaemonReload: "{{ k3s_show.stdout | regex_search('^NeedDaemonReload=(.*)$', '\\1', multiline=True) | first }}"
unit_is_preimage: "{{ unit_stat.stat.checksum | default('') == k3s_unit_preimage_sha256 }}"
unit_is_candidate: "{{ unit_stat.stat.checksum | default('') == k3s_unit_candidate_sha256 }}"
config_present: "{{ config_stats.results[0].stat.exists }}"
config_dropin_present: "{{ config_stats.results[1].stat.exists }}"
- name: Refuse on unexpected unit identity or service state
ansible.builtin.assert:
that:
- unit_stat.stat.exists
- unit_stat.stat.isreg
- unit_stat.stat.pw_name == 'root'
- unit_stat.stat.gr_name == 'root'
- unit_stat.stat.mode == '0644'
- unit_is_preimage or unit_is_candidate
- k3s_state.ActiveState == 'active'
- k3s_state.DropInPaths | default('') | length == 0
- k3s_state.NeedDaemonReload == 'no'
- not config_dropin_present
fail_msg: >-
Refusing: k3s unit or service state differs from the reviewed preimage
(sha256={{ unit_stat.stat.checksum | default('absent') }},
ActiveState={{ k3s_state.ActiveState | default('?') }},
DropInPaths={{ k3s_state.DropInPaths | default('?') }},
NeedDaemonReload={{ k3s_state.NeedDaemonReload | default('?') }},
config.yaml.d present={{ config_dropin_present }}).
Re-observe and review before overriding k3s_unit_preimage_sha256.
quiet: true
- name: Refuse a foreign config.yaml (apply on preimage only)
ansible.builtin.assert:
that: not config_present
fail_msg: >-
Refusing: {{ k3s_config_path }} already exists on an unconverged host;
merging into an unreviewed k3s config is out of scope.
quiet: true
when: k3s_kubeconfig_mode_action == 'apply' and unit_is_preimage
# ── Apply ────────────────────────────────────────────────────────────────
- name: Apply block
when: k3s_kubeconfig_mode_action == 'apply'
block:
- name: Read unit for in-memory transform (never printed)
ansible.builtin.slurp:
src: "{{ k3s_unit_path }}"
register: unit_raw
no_log: true
when: unit_is_preimage
- name: Compute candidate unit (exactly one 644 flag, two bytes changed)
ansible.builtin.set_fact:
unit_old: "{{ unit_raw.content | b64decode }}"
unit_new: "{{ unit_raw.content | b64decode | regex_replace(k3s_mode_flag_regex, '\\g<1>600') }}"
unit_flag_count: "{{ unit_raw.content | b64decode | regex_findall(k3s_mode_flag_regex) | length }}"
no_log: true
when: unit_is_preimage
- name: Verify candidate against pinned hashes
ansible.builtin.assert:
that:
- unit_old | hash('sha256') == k3s_unit_preimage_sha256
- unit_flag_count | int == 1
- unit_new | length == unit_old | length
- unit_new | hash('sha256') == k3s_unit_candidate_sha256
fail_msg: "Refusing: computed unit candidate does not match the reviewed transform."
quiet: true
when: unit_is_preimage
- name: Ensure private backup directory
ansible.builtin.file:
path: "{{ k3s_backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
when: unit_is_preimage
- name: Back up original unit (root-only)
# In --check the backup directory is not created, so the copy cannot be simulated.
ignore_errors: "{{ ansible_check_mode }}"
ansible.builtin.copy:
src: "{{ k3s_unit_path }}"
remote_src: true
dest: "{{ k3s_backup_dir }}/k3s.service.{{ k3s_unit_preimage_sha256[:12] }}"
owner: root
group: root
mode: "0600"
force: false
diff: false
register: unit_backup
when: unit_is_preimage
- name: Write k3s config.yaml with write-kubeconfig-mode 0600
ansible.builtin.copy:
content: "{{ k3s_config_content }}"
dest: "{{ k3s_config_path }}"
owner: root
group: root
mode: "0600"
register: config_write
- name: Rewrite the mode flag in the k3s unit (content not shown)
ansible.builtin.copy:
content: "{{ unit_new }}"
dest: "{{ k3s_unit_path }}"
owner: root
group: root
mode: "0644"
diff: false
no_log: true
register: unit_write
when: unit_is_preimage
- name: Report planned or applied change
ansible.builtin.debug:
msg: >-
unit {{ 'rewritten (644 -> 600)' if unit_is_preimage else 'already at candidate' }};
config.yaml {{ 'written' if config_write is changed else 'unchanged' }};
backup {{ unit_backup.dest | default(k3s_backup_dir ~ '/k3s.service.' ~ k3s_unit_preimage_sha256[:12]) if unit_is_preimage else 'n/a' }};
check_mode={{ ansible_check_mode }}
- name: Verify written unit hash
ansible.builtin.stat:
path: "{{ k3s_unit_path }}"
checksum_algorithm: sha256
register: unit_after
failed_when: unit_after.stat.checksum != k3s_unit_candidate_sha256
when: not ansible_check_mode
- name: Reload systemd and restart k3s
ansible.builtin.systemd:
name: k3s
daemon_reload: true
state: restarted
when: unit_write is changed or config_write is changed
# ── Rollback ─────────────────────────────────────────────────────────────
- name: Rollback block
when: k3s_kubeconfig_mode_action == 'rollback'
block:
- name: Require backup path
ansible.builtin.assert:
that: k3s_unit_backup is defined and k3s_unit_backup is match(k3s_backup_dir ~ '/')
fail_msg: "Pass -e k3s_unit_backup=<file under {{ k3s_backup_dir }}>"
quiet: true
- name: Stat backup (hash only)
ansible.builtin.stat:
path: "{{ k3s_unit_backup }}"
checksum_algorithm: sha256
register: backup_stat
failed_when: not backup_stat.stat.exists or backup_stat.stat.checksum != k3s_unit_preimage_sha256
- name: Restore original unit
ansible.builtin.copy:
src: "{{ k3s_unit_backup }}"
remote_src: true
dest: "{{ k3s_unit_path }}"
owner: root
group: root
mode: "0644"
diff: false
register: unit_restore
- name: Remove managed config.yaml
ansible.builtin.file:
path: "{{ k3s_config_path }}"
state: absent
register: config_remove
- name: Reload systemd and restart k3s
ansible.builtin.systemd:
name: k3s
daemon_reload: true
state: restarted
when: unit_restore is changed or config_remove is changed
# ── Post-verification (skipped in check mode) ────────────────────────────
- name: Post-verification
when: not ansible_check_mode
block:
- name: Wait for API readiness
ansible.builtin.command:
argv: [k3s, kubectl, get, --raw=/readyz]
register: readyz
until: readyz.rc == 0 and readyz.stdout == 'ok'
retries: 36
delay: 5
changed_when: false
- name: Wait for node Ready
ansible.builtin.command:
argv: [k3s, kubectl, wait, node, --all, --for=condition=Ready, --timeout=180s]
changed_when: false
- name: Stat admin kubeconfig (metadata only)
ansible.builtin.stat:
path: "{{ k3s_admin_kubeconfig }}"
get_checksum: false
register: kubeconfig_stat
- name: Verify admin kubeconfig ownership and mode
ansible.builtin.assert:
that:
- kubeconfig_stat.stat.exists
- kubeconfig_stat.stat.pw_name == 'root'
- kubeconfig_stat.stat.gr_name == 'root'
- kubeconfig_stat.stat.mode == ('0600' if k3s_kubeconfig_mode_action == 'apply' else '0644')
fail_msg: >-
admin kubeconfig is {{ kubeconfig_stat.stat.pw_name | default('?') }}:{{ kubeconfig_stat.stat.gr_name | default('?') }}
{{ kubeconfig_stat.stat.mode | default('?') }}
success_msg: >-
admin kubeconfig {{ kubeconfig_stat.stat.pw_name }}:{{ kubeconfig_stat.stat.gr_name }}
{{ kubeconfig_stat.stat.mode }}; API ready
# registries.yaml can hold registry logins; it was found 0644 on 2026-10-20
# by the RAIL-HO-WP-0014 posture check. k3s reads it as root at startup.
- name: Stat registries.yaml (metadata only)
ansible.builtin.stat:
path: /etc/rancher/k3s/registries.yaml
get_checksum: false
register: registries_stat
- name: Restrict registries.yaml to root (0600) when present
ansible.builtin.file:
path: /etc/rancher/k3s/registries.yaml
owner: root
group: root
mode: "0600"
when: k3s_kubeconfig_mode_action == 'apply' and registries_stat.stat.exists