#!/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/.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: # `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 host-disk-cleanup.md issue-to-merge.md network-change.md network-recovery.md pgdb-restore.md release.md rollback.md" GATED_REF_RUNBOOKS="mailcow-update.md ansible-operations.md home-assistant-maintenance.md matrix-e2ee-update.md pgdb-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