Skip to main content
RunBook Academy

← All checklists in Ansible

Before deploymentHost

Checklist: Ansible controller production readiness

20 items ·13 critical ·6 warn ·1 info

Run this before a controller is allowed to hold production credentials for the first time, and again after any change to its Python environment, its collections or its ansible.cfg. Run it on the controller itself, as the account that runs the automation - not as your own user, and not under sudo. Several items check file modes and configuration resolution, and both answers change with the account.

What a failure means

The controller is the one machine in the estate that can reach every other machine with elevated privilege. A weakness here is not a controller problem, it is a fleet problem: whoever holds the controller holds every host in the inventory. Treat every critical item as blocking, and resolve it before the controller is given a production vault password.

The warn items are about whether you will be able to reconstruct what happened afterwards. They do not stop a run, they stop an investigation.

Access this needs

Reading the vault password file mode, the SSH key modes and last output all require the automation account. logrotate and filesystem checks need read access to /etc. Nothing in this checklist writes anything, and nothing in it contacts a managed node.

Where the evidence goes

Capture the full output into the commissioning ticket for the controller, with the date and the account it was run as. The two items with no command - out-of-band access and a tested rebuild - are attested by a named person, not by a command, so record who attested them.

Sign-off

  • Operator: _________________ Date: ___________
  • Reviewer: ________________ Date: ___________

Critical13 items

  1. have=$(ansible --version | sed -n '1s/.*\[core \(.*\)\].*/\1/p')
    want=$(sed -n 's/^ansible-core==//p' requirements.txt)
    [ "$have" = "$want" ] || echo "FINDING: controller runs core $have, requirements.txt pins $want"
  2. comm -23 <(grep -vE '^[[:space:]]*(#|$)' requirements.txt | tr 'A-Z' 'a-z' | LC_ALL=C sort) \
             <(pip freeze | tr 'A-Z' 'a-z' | LC_ALL=C sort)
  3. python3 - <<'PY'
    import json, subprocess, yaml
    out = subprocess.run(['ansible-galaxy', 'collection', 'list', '--format', 'json'],
                         capture_output=True, text=True).stdout or '{}'
    have = {n: v['version'] for p in json.loads(out).values() for n, v in p.items()}
    for c in (yaml.safe_load(open('requirements.yml')) or {}).get('collections') or []:
        name, want = (c['name'], c.get('version')) if isinstance(c, dict) else (c, None)
        if want is None:
            print(f'UNPINNED: {name} has no version in requirements.yml')
        elif have.get(name) != want:
            print(f'DRIFT: {name} pinned to {want}, controller has {have.get(name)}')
    PY
  4. ansible-config validate
  5. ansible-config dump | awk '/^HOST_KEY_CHECKING/ && !/= True/ {print "FINDING: " $0}'
  6. stat -c '%a %U %n' "$(ansible-config dump | sed -n 's/^DEFAULT_VAULT_PASSWORD_FILE([^)]*) = //p')" 2>/dev/null \
      | awk '$1 != "600" && $1 != "400" {print "FINDING: vault password file is mode " $1 " owned by " $2 ": " $3}'
  7. grep -rInE 'vault[-_ ]?(pass|password)' --include='*.yml' --include='*.yaml' --include='*.cfg' \
      --include='*.sh' --exclude-dir=.git . | grep -v 'vault_password_file[[:space:]]*=[[:space:]]*[~/]'
  8. find ~/.ssh -maxdepth 1 -type f -name 'id_*' ! -name '*.pub' -printf '%m %p\n' | awk '$1 != "600" && $1 != "400" {print "FINDING: private key " $2 " is mode " $1}'
  9. awk -F: '$3>=1000 && $3<65534 && $7 !~ /nologin|\/false/ {print $1}' /etc/passwd; last -n 20
  10. ansible -i inventories/production all --list-hosts | tr -d ' ' \
      | grep -Fx "$(hostname -f)" && echo "FINDING: the controller is a target of its own production inventory"

Warning6 items

  1. ansible-config dump --only-changed
  2. p=$(ansible-config dump | sed -n 's/^DEFAULT_LOG_PATH([^)]*) = //p')
    { [ -n "$p" ] && [ "$p" != "None" ] && [ -w "$p" ]; } \
      || echo "FINDING: log_path is '$p' - not set, or not writable by this account"
  3. p=$(ansible-config dump | sed -n 's/^DEFAULT_LOG_PATH([^)]*) = //p')
    grep -rl "$p" /etc/logrotate.d/ 2>/dev/null || echo "FINDING: no logrotate rule references $p"
    df --output=pcent "$(dirname "$p")" | tail -1 | tr -dc '0-9' | awk '$1+0 > 80 {print "FINDING: log filesystem is " $1 "% full"}'
  4. ansible-config dump | grep '^DEFAULT_FORKS'; echo "controller CPUs: $(nproc)"
  5. timedatectl show -p NTPSynchronized --value | grep -qx yes || echo "FINDING: controller clock is not NTP-synchronised"
  6. ansible --version | grep -E 'python version|executable location'

Info1 item

  1. ansible --version; ansible-galaxy collection list; ansible-config dump --only-changed; pip freeze