Files
esh-pfi-infrastructure/playbooks/audit-host-conventions.yaml
T
vh abef67aacf feat(fleet): pin identity/group/path conventions + read-only audit playbook
Operator ratified four conventions on 2026-09-14. docs/pfi/fleet-conventions.md
is the pin; playbooks/audit-host-conventions.yaml is its instrument.

Pinned, verified free on all eight surveyed hosts (dynamically-allocated
system accounts cluster in 989-999 and descend, so 800-899 is safe):
  800-849  svc-* service accounts
  850      infra-ops uid+gid
  851      docker gid
  852-899  reserved for fleet-wide groups
  1000     the human account (vh)

Deliberately a pin for NEW hosts, not a migration mandate. The UID drift
(infra-ops is 1001/1002/1003/2001) is tolerable because there is no central
identity anywhere and a UID only has to agree where files cross hosts. They
do on /mnt/smithy — but that export is owned by Synology UIDs that resolve on
neither host and is 0777 throughout, so cross-host sharing works today
BECAUSE permissions are wide open. Aligning UIDs does not fix something
broken; it earns the right to drop that 777. Recorded as such rather than as
an urgent defect.

The audit playbook reports and never enforces, so a standard cannot quietly
become a flag day. Verified against nh3-dev, ana-docker, corviduo-dev and
nh3-extdev; it immediately surfaced two things the survey had missed —
llmuser holds sudo AND docker on ana-docker, and seven stacks on corviduo-dev
run from outside /opt/docker/compose (three under /home/vh, four under /opt,
including the three CI/CD-driven Worldtree deployments that must not be
moved).

Also supersedes the CLAUDE.md posture that made corviduo-dev the one host
excluded from fleet normalisation: the operator ruled all ops on it belong to
infra-ops. Its application layer stays CI/CD-owned.
2026-09-14 13:30:30 -07:00

124 lines
6.1 KiB
YAML

# 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 <host> --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-<service> 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/<stack>/ 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"