diff --git a/ansible/roles/anvil/handlers/main.yml b/ansible/roles/anvil/handlers/main.yml index 805b137..03acc37 100644 --- a/ansible/roles/anvil/handlers/main.yml +++ b/ansible/roles/anvil/handlers/main.yml @@ -7,3 +7,6 @@ ansible.builtin.systemd: name: anvil state: restarted + # Under --check on a fresh host the unit isn't installed yet; flush_handlers would + # otherwise fail trying to restart a non-existent service. + when: not ansible_check_mode or 'anvil.service' in (ansible_facts.services | default({})) diff --git a/ansible/roles/anvil/tasks/main.yml b/ansible/roles/anvil/tasks/main.yml index 5c201ab..2edbacd 100644 --- a/ansible/roles/anvil/tasks/main.yml +++ b/ansible/roles/anvil/tasks/main.yml @@ -15,6 +15,23 @@ create_home: true shell: /usr/sbin/nologin +# Under --check the user/apt/Foundry tasks below are no-ops, so on a fresh host the +# anvil user and Foundry binaries don't exist yet — become_user, cast, and the +# systemd unit would all hard-fail. Probe real on-host state (read-only, runs in +# check mode) so those tasks skip in check mode while still previewing drift on an +# already-provisioned host. +- name: Probe whether the anvil user already exists on the target + ansible.builtin.getent: + database: passwd + key: "{{ anvil_user }}" + fail_key: false + +# Service guards below reuse baseline's service_facts; gather here too so the role +# stays correct when run standalone (e.g. --tags anvil) without baseline first. +- name: Probe service facts if not already gathered + ansible.builtin.service_facts: + when: ansible_facts.services is not defined + - name: Install Foundry bootstrap prerequisites ansible.builtin.apt: name: [curl, git, jq, ca-certificates] @@ -52,7 +69,11 @@ executable: /bin/bash environment: FOUNDRY_DIR: "{{ foundry_dir }}" - when: not anvil_bin.stat.exists + when: + - not anvil_bin.stat.exists + # become_user requires the anvil user to exist; --check didn't create it. + # fail_key:false stores a missing user as {user: None}, so test the value. + - not ansible_check_mode or (getent_passwd | default({})).get(anvil_user) - name: Ensure /etc/anvil exists ansible.builtin.file: @@ -69,7 +90,10 @@ register: anvil_env_stat - name: Generate and install the shared mnemonic - when: not anvil_env_stat.stat.exists + when: + - not anvil_env_stat.stat.exists + # cast lives in the Foundry bin; under --check on a fresh host it isn't installed. + - not ansible_check_mode or anvil_bin.stat.exists block: - name: Generate a fresh mnemonic with cast ansible.builtin.command: @@ -135,6 +159,7 @@ name: anvil enabled: true state: started + when: not ansible_check_mode or 'anvil.service' in (ansible_facts.services | default({})) - name: Wait for the anvil RPC to answer on loopback ansible.builtin.uri: @@ -155,3 +180,7 @@ # Assert it's OUR chain, not a stale process on the port answering 200. - (anvil_rpc_probe.json.result | default('0x0')) | int(0, 16) == anvil_chain_id changed_when: false + # Skip entirely in check mode: --check never (re)starts anvil, so this liveness + # probe would falsely fail against a stopped-but-provisioned host. It previews no + # drift (changed_when:false) — there is nothing to verify in a dry run. + when: not ansible_check_mode diff --git a/ansible/roles/baseline/handlers/main.yml b/ansible/roles/baseline/handlers/main.yml index 9031d3d..50374a6 100644 --- a/ansible/roles/baseline/handlers/main.yml +++ b/ansible/roles/baseline/handlers/main.yml @@ -1,10 +1,16 @@ --- +# Both units come from baseline_packages, a no-op under --check, so on a fresh host +# the unit may not exist when a flush (e.g. anvil's flush_handlers) fires the handler. +# Guard on real on-host state (service_facts, gathered in tasks/main.yml): run on a +# provisioned host (drift preview), skip a missing unit in check mode. - name: Reload nftables ansible.builtin.systemd: name: nftables state: restarted + when: not ansible_check_mode or 'nftables.service' in (ansible_facts.services | default({})) - name: Restart fail2ban ansible.builtin.systemd: name: fail2ban state: restarted + when: not ansible_check_mode or 'fail2ban.service' in (ansible_facts.services | default({})) diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index 2af4d8e..a1b6386 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -9,6 +9,14 @@ update_cache: true cache_valid_time: 3600 +# Under --check the install above is a no-op, so packaged units (fail2ban, chrony, +# nftables) may not exist yet on a fresh host and a systemd enable/start would +# hard-fail ("Could not find the requested service"). Probe real on-host state +# (read-only, runs in check mode) so the service tasks below can skip a missing +# unit in check mode while still previewing drift on an already-provisioned host. +- name: Probe which service units already exist on the target + ansible.builtin.service_facts: + # --- Admin account (must exist + have a key BEFORE ssh_hardening locks root) --- # Resolve the user and key set from the control machine when left empty; explicit # values always win. Lookups run on the control node — that's the operator's box. @@ -87,12 +95,27 @@ shell: /bin/bash create_home: true +# authorized_key resolves the user's ~/.ssh to write the key, so the account must +# already exist. Under --check the create task above is a no-op, so on a not-yet- +# provisioned host the user is absent and the module hard-fails ("Either user must +# exist or you must provide full path to key file in check mode"). Probe real +# on-host state (read-only, runs in check mode) and skip the key install only when +# check mode meets a missing user; real runs and check runs on a provisioned host +# (where drift is worth previewing) always proceed. +- name: Probe whether the admin user already exists on the target + ansible.builtin.getent: + database: passwd + key: "{{ ssh_admin_user_effective }}" + fail_key: false + - name: Install admin authorized keys ansible.posix.authorized_key: user: "{{ ssh_admin_user_effective }}" key: "{{ item }}" state: present loop: "{{ ssh_admin_keys }}" + # fail_key:false stores a missing user as {user: None}, so test the value, not `in`. + when: not ansible_check_mode or (getent_passwd | default({})).get(ssh_admin_user_effective) # --- Firewall: default-deny inbound, SSH only --------------------------------- - name: Install nftables ruleset @@ -110,6 +133,7 @@ name: nftables enabled: true state: started + when: not ansible_check_mode or 'nftables.service' in (ansible_facts.services | default({})) # --- Automatic security patches ---------------------------------------------- - name: Enable unattended-upgrades @@ -140,6 +164,7 @@ name: fail2ban enabled: true state: started + when: not ansible_check_mode or 'fail2ban.service' in (ansible_facts.services | default({})) # --- Time sync ---------------------------------------------------------------- - name: Enable and start chrony @@ -147,6 +172,7 @@ name: chrony enabled: true state: started + when: not ansible_check_mode or 'chrony.service' in (ansible_facts.services | default({})) # --- DevSec hardening (LAST: ssh_hardening disables root + password auth) ------ - name: Apply DevSec OS hardening diff --git a/ansible/roles/caddy/handlers/main.yml b/ansible/roles/caddy/handlers/main.yml index 6d9afe1..873f60b 100644 --- a/ansible/roles/caddy/handlers/main.yml +++ b/ansible/roles/caddy/handlers/main.yml @@ -5,3 +5,6 @@ ansible.builtin.systemd: name: caddy state: restarted + # Under --check on a fresh host the unit isn't installed yet; flush_handlers would + # otherwise fail trying to restart a non-existent service. + when: not ansible_check_mode or 'caddy.service' in (ansible_facts.services | default({})) diff --git a/ansible/roles/caddy/tasks/add_user.yml b/ansible/roles/caddy/tasks/add_user.yml index 57902b9..5421d22 100644 --- a/ansible/roles/caddy/tasks/add_user.yml +++ b/ansible/roles/caddy/tasks/add_user.yml @@ -20,7 +20,14 @@ failed_when: false - name: Mint '{{ caddy_target_user }}' - when: caddy_user_check.rc != 0 + # Never mint in check mode: minting needs the caddy binary (hash-password, which + # auto-skips under --check), writes the TSV, and reveals a password — all mutating + # side effects a dry run must not perform. Gating on ansible_check_mode first also + # means caddy_user_check.rc — undefined when the existence-check command auto-skips + # under --check — is never the deciding factor; default it so it can't error. + when: + - not ansible_check_mode + - caddy_user_check.rc | default(1) != 0 block: - name: Generate a random password ansible.builtin.set_fact: diff --git a/ansible/roles/caddy/tasks/main.yml b/ansible/roles/caddy/tasks/main.yml index 7d2b952..b00429f 100644 --- a/ansible/roles/caddy/tasks/main.yml +++ b/ansible/roles/caddy/tasks/main.yml @@ -25,11 +25,28 @@ # Rebuild the Caddyfile import block from the TSV registry. Idempotent: identical # registry -> identical content -> no change -> no restart. +# Under --check the user-mint tasks don't write (the existence probe is a command +# that auto-skips; the lineinfile registry write is a no-op), so on a fresh host the +# TSV registry is never created and the slurp below would fail (file not found). +# Probe for it (read-only, runs in check mode); the slurp/render skip in check mode +# when it's absent, but still preview drift on an already-provisioned host. +- name: Check whether the basic-auth registry exists + ansible.builtin.stat: + path: "{{ caddy_users_tsv }}" + register: caddy_tsv_stat + +# Service guards below reuse baseline's service_facts; gather here too so the role +# stays correct when run standalone (e.g. --tags caddy) without baseline first. +- name: Probe service facts if not already gathered + ansible.builtin.service_facts: + when: ansible_facts.services is not defined + - name: Read the basic-auth user registry ansible.builtin.slurp: src: "{{ caddy_users_tsv }}" register: caddy_tsv_raw no_log: true + when: not ansible_check_mode or caddy_tsv_stat.stat.exists - name: Render the basic_auth import file from the registry ansible.builtin.copy: @@ -49,6 +66,8 @@ } no_log: true notify: Restart caddy + # caddy_tsv_raw is only defined when the slurp above ran (skipped in check mode). + when: not ansible_check_mode or caddy_tsv_stat.stat.exists - name: Install the Caddyfile ansible.builtin.template: @@ -59,12 +78,15 @@ mode: "0644" validate: "caddy validate --adapter caddyfile --config %s" notify: Restart caddy + # validate shells out to the caddy binary, absent under --check on a fresh host. + when: not ansible_check_mode or 'caddy.service' in (ansible_facts.services | default({})) - name: Enable and start caddy ansible.builtin.systemd: name: caddy enabled: true state: started + when: not ansible_check_mode or 'caddy.service' in (ansible_facts.services | default({})) - name: Apply caddy config changes now ansible.builtin.meta: flush_handlers