Files
vps/scripts/validate-repo.sh
T

207 lines
8.5 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# validate-repo.sh — repo-wide validation for the VPS ops hub.
#
# Run from anywhere; must be executed from a git worktree of this repo.
# Exit 0 = pass (warnings allowed), non-zero = violations found.
#
# Checks:
# 1. Secret scan — tracked/working-tree files must not look like secrets.
# 2. Inventory — ansible inventory (display_name) ↔ inventory/hosts.md
# ↔ hosts/<name>.md cross-check (SKIP if no ansible CLI).
# 3. Links — relative markdown links must resolve.
# 4. Runbook spec — RUNBOOKS.md: Last reviewed + STOP on every runbook;
# six-field markers on procedure-type change runbooks;
# gate markers on gated command references.
# 5. Ansible — inventory parse + per-playbook --syntax-check
# (SKIP if no ansible CLI).
#
# NOTE: ansible temp/home are pinned inside the repo (./.ansible) so the
# script works in sandboxed/CI-like environments without touching ~/.ansible.
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
FAIL=0
WARN=0
say() { printf '%s\n' "$*"; }
ok() { printf ' [ok] %s\n' "$*"; }
skip() { printf ' [skip] %s\n' "$*"; }
warn() { printf ' [warn] %s\n' "$*"; WARN=$((WARN+1)); }
fail() { printf ' [FAIL] %s\n' "$*"; FAIL=$((FAIL+1)); }
# ---------------------------------------------------------------------------
say "== 1. Secret scan (tracked + untracked non-ignored files) =="
SECRET_PATH_RE='(^|/)(\.env$|\.env\.[^.].*|mailcow\.conf|\.smtp-credentials|\.admin-token)$|\.(pem|key)$|(^|/)id_(rsa|ed25519|ecdsa)$'
while IFS= read -r f; do
# .env.example is the sanctioned non-secret placeholder (see .gitignore).
if printf '%s' "$f" | grep -qE "$SECRET_PATH_RE" && ! printf '%s' "$f" | grep -qE '(^|/)\.env\.example$'; then
fail "secret-like path present: $f"
fi
done < <(git ls-files -co --exclude-standard)
if git grep -qIl '-----BEGIN [A-Z ]*PRIVATE KEY-----' 2>/dev/null; then
fail "private key material found in tracked files"
else
ok "no secret-like paths / private-key material"
fi
# ---------------------------------------------------------------------------
say "== 2. Inventory cross-check (ansible display_name ↔ hosts.md ↔ hosts/) =="
if command -v ansible-inventory >/dev/null 2>&1; then
export ANSIBLE_LOCAL_TEMP="$REPO_ROOT/.ansible/tmp"
export ANSIBLE_HOME="$REPO_ROOT/.ansible"
mkdir -p "$REPO_ROOT/.ansible/tmp"
INV_JSON="$(mktemp)"
if (cd ansible && ansible-inventory --list > "$INV_JSON" 2>/dev/null); then
if python3 - "$INV_JSON" <<'PYEOF'
import json, sys, re, pathlib
inv = json.load(open(sys.argv[1]))
hostvars = inv.get('_meta', {}).get('hostvars', {})
root = pathlib.Path('.')
hosts_md = (root / 'inventory' / 'hosts.md').read_text(encoding='utf-8')
errors = []
for key, v in sorted(hostvars.items()):
dn = v.get('display_name')
if not dn:
errors.append(f"inventory host {key!r} has no display_name")
continue
if not (root / 'hosts' / f'{dn}.md').exists():
errors.append(f"hosts/{dn}.md missing for inventory host {key}")
if dn not in hosts_md:
errors.append(f"display_name {dn!r} (inventory key {key}) not found in inventory/hosts.md")
display_names = {v.get('display_name') for v in hostvars.values() if v.get('display_name')}
# Reverse: rows marked '✓ (key)' in hosts.md must exist in the ansible inventory.
for line in hosts_md.splitlines():
m = re.match(r'^\|\s*\*{0,2}([^*|]+?)\*{0,2}\s*\|\s*[^|]*?\s*\|\s*[^|]*?\s*\|\s*[^|]*?\s*\|\s*✓\s*\(([^)]+)\)', line)
if m:
host, invkey = m.group(1).strip(), m.group(2).strip()
if invkey not in hostvars:
errors.append(f"hosts.md row {host!r} marked Ansible '✓' but inventory key {invkey!r} missing")
for e in errors:
print(e)
sys.exit(1 if errors else 0)
PYEOF
then ok "inventory cross-check passed"
else fail "inventory cross-check violations (see above)"
fi
rm -f "$INV_JSON"
else
fail "ansible-inventory --list failed"
fi
else
skip "ansible-inventory not available"
fi
# ---------------------------------------------------------------------------
say "== 3. Relative markdown link check =="
if python3 - <<'PYEOF'
import pathlib, re, subprocess, sys
root = pathlib.Path('.')
# Only repo content: tracked + untracked non-ignored files (excludes
# gitignored tooling dirs like .agents/ and .claude/).
tracked = subprocess.check_output(['git', 'ls-files', '--', '*.md'], text=True).split()
untracked = subprocess.check_output(
['git', 'ls-files', '-o', '--exclude-standard', '--', '*.md'], text=True).split()
files = sorted(set(pathlib.Path(f) for f in tracked + untracked))
link_re = re.compile(r'\[[^\]]*\]\(([^)]+)\)')
errors = []
for f in files:
2026-08-23 17:58:19 +08:00
# `git ls-files` includes paths deleted in the working tree. They cannot
# contain links and must not make validation fail before their deletion is
# staged.
if not f.exists():
continue
try:
text = f.read_text(encoding='utf-8')
except UnicodeDecodeError:
continue
for m in link_re.finditer(text):
target = m.group(1).strip()
if not target or target.startswith(('#', 'http://', 'https://', 'mailto:', 'tel:', '{{')):
continue
# strip optional anchor and surrounding quotes
target = target.split('#', 1)[0].strip().strip('"\'')
if not target:
continue
resolved = (f.parent / target)
if not resolved.exists():
errors.append(f"{f}: broken link -> {target}")
for e in errors:
print(e)
sys.exit(1 if errors else 0)
PYEOF
then ok "all relative markdown links resolve"
else fail "broken markdown links (see above)"
fi
# ---------------------------------------------------------------------------
say "== 4. Runbook spec compliance (RUNBOOKS.md) =="
PROCEDURE_RUNBOOKS="fix-ci.md issue-to-merge.md network-change.md network-recovery.md release.md rollback.md"
GATED_REF_RUNBOOKS="mailcow-update.md ansible-operations.md home-assistant-maintenance.md matrix-e2ee-update.md vaultwarden-sqlite-to-postgres.md"
RUNBOOK_INDEX="runbooks/README.md"
for f in runbooks/*.md; do
base="$(basename "$f")"
case "$base" in
README.md|_template.md) continue ;;
esac
if ! grep -q "Last reviewed" "$f"; then fail "$f: missing 'Last reviewed'"; fi
if ! grep -qE "STOP" "$f"; then fail "$f: missing explicit STOP condition"; fi
if ! grep -qE '^## Purpose$' "$f"; then fail "$f: missing '## Purpose'"; fi
if ! grep -qE '^## Scope$' "$f"; then fail "$f: missing '## Scope'"; fi
index_matches="$(grep -cF "]($base)" "$RUNBOOK_INDEX" || true)"
if [ "$index_matches" -ne 1 ]; then
fail "$f: expected exactly one entry in $RUNBOOK_INDEX (found $index_matches)"
fi
if grep -F "]($base)" "$RUNBOOK_INDEX" | grep -qF "| change"; then
case " $PROCEDURE_RUNBOOKS $GATED_REF_RUNBOOKS " in
*" $base "*) ;;
*) fail "$f: change runbook is not classified as procedure or gated-command" ;;
esac
fi
done
for base in $PROCEDURE_RUNBOOKS; do
f="runbooks/$base"
[ -f "$f" ] || { fail "$f: expected procedure-type runbook missing"; continue; }
for marker in '**Action**' '**Expected**' '**Decision**' '**Verification**'; do
if ! grep -qF "$marker" "$f"; then fail "$f: procedure-type runbook missing $marker"; fi
done
done
for base in $GATED_REF_RUNBOOKS; do
f="runbooks/$base"
[ -f "$f" ] || { fail "$f: expected gated-command runbook missing"; continue; }
if ! grep -qE "(Approval gates|confirm|--yes|explicit approval|confirmation)" "$f"; then
fail "$f: gated-command runbook missing approval/confirmation gate"
fi
done
if [ "$FAIL" -gt 0 ]; then :; else ok "runbook spec checks passed"; fi
# ---------------------------------------------------------------------------
say "== 5. Ansible inventory + playbook syntax =="
if command -v ansible-playbook >/dev/null 2>&1; then
export ANSIBLE_LOCAL_TEMP="$REPO_ROOT/.ansible/tmp"
export ANSIBLE_HOME="$REPO_ROOT/.ansible"
mkdir -p "$REPO_ROOT/.ansible/tmp"
syntax_fail=0
for p in ansible/playbooks/*.yml; do
if ! (cd ansible && ansible-playbook --syntax-check "playbooks/$(basename "$p")" >/dev/null 2>&1); then
fail "syntax-check failed: $p"
syntax_fail=1
fi
done
[ "$syntax_fail" -eq 0 ] && ok "all playbooks passed --syntax-check"
else
skip "ansible not available (syntax-check skipped)"
fi
# ---------------------------------------------------------------------------
say ""
if [ "$FAIL" -gt 0 ]; then
say "RESULT: FAIL ($FAIL violations, $WARN warnings)"
exit 1
else
say "RESULT: PASS ($WARN warnings)"
exit 0
fi