# audit-host-conventions — report a host's drift from docs/pfi/fleet-conventions.md # # READ-ONLY. Changes nothing, ever. Ratified 2026-09-14; the conventions doc is # the source of truth and this playbook is its instrument. # # Deliberately reports rather than enforces. The conventions are a pin for NEW # hosts, not a migration mandate — existing hosts converge when there is an # independent reason to touch them (doc § 5 Non-goals). An enforcing playbook # would quietly turn a standard into a flag day. # # scripts/elway --playbook playbooks/audit-host-conventions.yaml vars: pin_infra_ops_uid: "850" pin_docker_gid: "851" svc_range_lo: "800" svc_range_hi: "849" steps: - name: Human account shell: | if getent passwd vh >/dev/null; then echo " OK human account is vh ($(id -u vh))" elif getent passwd lkraven >/dev/null; then echo " LEGACY human account is lkraven ($(id -u lkraven)) — standard is vh; do NOT retro-rename (doc 1.1)" else echo " DRIFT no vh and no lkraven on this host" fi changed_when: "false" - name: Ops account — name, uid, home mode, sudo shell: | getent passwd infra-ops >/dev/null || { echo " DRIFT no infra-ops account"; exit 0; } uid=$(id -u infra-ops) [ "$uid" = "{{ pin_infra_ops_uid }}" ] \ && echo " OK infra-ops uid=$uid (pinned)" \ || echo " LEGACY infra-ops uid=$uid — pin is {{ pin_infra_ops_uid }} (doc 1.2 / 3; tolerable, costs the 777 on NFS)" m=$(stat -c '%a %U:%G' /home/infra-ops 2>/dev/null) case "$m" in "700 infra-ops:infra-ops") echo " OK /home/infra-ops $m" ;; *) echo " DRIFT /home/infra-ops $m — want 700 infra-ops:infra-ops" ;; esac sudo -n grep -qE '^infra-ops\s+ALL=\(ALL\)\s+NOPASSWD' /etc/sudoers.d/infra-ops 2>/dev/null \ && echo " OK sudoers.d/infra-ops NOPASSWD present" \ || echo " DRIFT sudoers.d/infra-ops missing or not NOPASSWD" sudo: true changed_when: "false" - name: docker group — gid pin and membership shell: | e=$(getent group docker) || { echo " N/A no docker group (host does not run Docker)"; exit 0; } gid=$(echo "$e" | cut -d: -f3); mem=$(echo "$e" | cut -d: -f4) [ "$gid" = "{{ pin_docker_gid }}" ] \ && echo " OK docker gid=$gid (pinned)" \ || echo " LEGACY docker gid=$gid — pin is {{ pin_docker_gid }} (assigned by docker-ce; pin with groupadd BEFORE install)" echo "$mem" | tr ',' '\n' | grep -qx infra-ops \ && echo " OK infra-ops in docker group" \ || echo " DRIFT infra-ops NOT in docker group — it cannot deploy here" echo " members: $mem" changed_when: "false" - name: Service accounts — naming and range # Two failures are reported separately because they have different fixes: # a non-svc- name is cosmetic, a service account in the human UID range is # the one that makes /etc/passwd unable to tell a daemon from a person. shell: | found=0 while IFS=: read -r name _ uid gid _ home shell; do case "$name" in vh|lkraven|infra-ops|nobody) continue ;; esac found=1 case "$name" in svc-*) echo " OK $name uid=$uid" ;; *) echo " LEGACY $name uid=$uid — convention is svc- in {{ svc_range_lo }}-{{ svc_range_hi }} (doc 1.3)" ;; esac [ "$uid" -ge 1000 ] && [ "${name#svc-}" != "$name" ] \ && echo " DRIFT $name is svc-named but uid=$uid is in the HUMAN range" done < <(awk -F: '$3>=1000 && $3<65000' /etc/passwd) [ "$found" = 0 ] && echo " OK no extra accounts" echo " --- accounts with a path to root ---" for g in sudo docker; do getent group "$g" >/dev/null || continue for u in $(getent group "$g" | cut -d: -f4 | tr ',' ' '); do case "$u" in vh|lkraven|infra-ops) continue ;; esac echo " REVIEW $u is in '$g' — root-equivalent; a service account should not be (doc 1.3)" done done changed_when: "false" - name: Deploy tree ownership and modes shell: | [ -d /opt/docker ] || { echo " N/A no /opt/docker on this host"; exit 0; } for p in /opt/docker /opt/docker/compose /opt/docker/conf; do [ -e "$p" ] || continue s=$(stat -c '%a %U:%G' "$p") [ "$s" = "2775 root:docker" ] && echo " OK $p $s" || echo " DRIFT $p $s — want 2775 root:docker" done bad=$(sudo -n find /opt/docker/compose -maxdepth 2 -name '.env' ! -perm 0640 -printf '%m %p\n' 2>/dev/null | head -5) [ -z "$bad" ] && echo " OK every stack .env is 0640" || { echo " DRIFT .env not 0640:"; echo "$bad" | sed 's/^/ /'; } ww=$(sudo -n find /opt/docker -maxdepth 2 -perm -o=w -printf '%m %p\n' 2>/dev/null | head -5) [ -z "$ww" ] && echo " OK nothing world-writable under /opt/docker" || { echo " DRIFT world-writable:"; echo "$ww" | sed 's/^/ /'; } sudo: true changed_when: "false" - name: Stacks outside the convention (invisible to fleet automation) # A stack not under /opt/docker/compose// is skipped by every tool # that walks that path — silently. This is how `talk` was missed by the # docker-ce upgrade's restart loop on 2026-09-14 (doc 4). shell: | command -v docker >/dev/null || { echo " N/A no docker"; exit 0; } n=0 for wd in $(docker ps -q | xargs -r docker inspect \ --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' 2>/dev/null | sort -u); do [ -z "$wd" ] && continue case "$wd" in /opt/docker/compose/*) ;; *) echo " DRIFT running stack outside the convention: $wd"; n=$((n+1)) ;; esac done [ "$n" = 0 ] && echo " OK every running compose stack lives under /opt/docker/compose/" loose=$(docker ps --format '{{.Names}}' --filter 'label=com.docker.compose.project' \ | comm -13 - <(docker ps --format '{{.Names}}' | sort) 2>/dev/null | head -3) true changed_when: "false"