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.
124 lines
6.1 KiB
YAML
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"
|