--- # 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= --check --diff # ansible-playbook ansible/k3s-kubeconfig-mode.yml -e target= --diff # # Rollback (restores the backed-up unit, removes config.yaml, restarts k3s): # ansible-playbook ansible/k3s-kubeconfig-mode.yml -e target= \ # -e k3s_kubeconfig_mode_action=rollback \ # -e k3s_unit_backup=/root/k3s-kubeconfig-mode-backup/k3s.service. # Manual rollback equivalent on the host: # sudo install -o root -g root -m 0644 /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=" 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