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
323 lines
14 KiB
YAML
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
|