feat: add gated Compose deploy and make inventory the host source of truth
Keep sanitized Compose sources in-repo with a confirmation-gated Ansible playbook, add repo-wide validation, tighten runbook ownership/STOP/review metadata, and archive stale research docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/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:
|
||||
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 vaultwarden-sqlite-to-postgres.md"
|
||||
for f in runbooks/*.md; do
|
||||
base="$(basename "$f")"
|
||||
[ "$base" = "README.md" ] && continue
|
||||
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
|
||||
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
|
||||
Reference in New Issue
Block a user