#!/usr/bin/env python3 """Matrix/Conduit Scaffold Validator — Issue #183 Acceptance Proof Validates that infra/matrix/ contains a complete, well-formed deployment scaffold. Run this after any scaffold change to ensure #183 acceptance criteria remain met. Usage: python3 infra/matrix/scripts/validate-scaffold.py python3 infra/matrix/scripts/validate-scaffold.py --json Exit codes: 0 = all checks passed 1 = one or more checks failed """ import argparse import json import os import re import subprocess import sys from pathlib import Path try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False class Validator: def __init__(self, base_dir: Path): self.base_dir = base_dir.resolve() self.checks = [] self.passed = 0 self.failed = 0 def _add(self, name: str, status: bool, detail: str): self.checks.append({"name": name, "status": "PASS" if status else "FAIL", "detail": detail}) if status: self.passed += 1 else: self.failed += 1 def require_files(self): """Check that all required scaffold files exist.""" required = [ "README.md", "prerequisites.md", "docker-compose.yml", "conduit.toml", ".env.example", "deploy-matrix.sh", "host-readiness-check.sh", "caddy/Caddyfile", "scripts/deploy-conduit.sh", "docs/RUNBOOK.md", ] missing = [] for rel in required: path = self.base_dir / rel if not path.exists(): missing.append(rel) self._add( "Required files present", len(missing) == 0, f"Missing: {missing}" if missing else f"All {len(required)} files found", ) def docker_compose_valid(self): """Validate docker-compose.yml is syntactically valid YAML.""" path = self.base_dir / "docker-compose.yml" if not path.exists(): self._add("docker-compose.yml valid YAML", False, "File does not exist") return try: with open(path, "r") as f: content = f.read() if HAS_YAML: yaml.safe_load(content) else: # Basic YAML brace balance check if content.count("{") != content.count("}"): raise ValueError("Brace mismatch") # Must reference conduit image or build has_conduit = "conduit" in content.lower() self._add( "docker-compose.yml valid YAML", has_conduit, "Valid YAML and references Conduit" if has_conduit else "Valid YAML but missing Conduit reference", ) except Exception as e: self._add("docker-compose.yml valid YAML", False, str(e)) def conduit_toml_valid(self): """Validate conduit.toml has required sections.""" path = self.base_dir / "conduit.toml" if not path.exists(): self._add("conduit.toml required keys", False, "File does not exist") return with open(path, "r") as f: content = f.read() required_keys = ["server_name", "port", "[database]"] missing = [k for k in required_keys if k not in content] self._add( "conduit.toml required keys", len(missing) == 0, f"Missing keys: {missing}" if missing else "Required keys present", ) def env_example_complete(self): """Validate .env.example has required variables.""" path = self.base_dir / ".env.example" if not path.exists(): self._add(".env.example required variables", False, "File does not exist") return with open(path, "r") as f: content = f.read() required_vars = ["MATRIX_DOMAIN", "ADMIN_USER", "ADMIN_PASSWORD"] missing = [v for v in required_vars if v not in content] self._add( ".env.example required variables", len(missing) == 0, f"Missing vars: {missing}" if missing else "Required variables present", ) def shell_scripts_executable(self): """Check that shell scripts are executable and pass bash -n.""" scripts = [ self.base_dir / "deploy-matrix.sh", self.base_dir / "host-readiness-check.sh", self.base_dir / "scripts" / "deploy-conduit.sh", ] errors = [] for script in scripts: if not script.exists(): errors.append(f"{script.name}: missing") continue if not os.access(script, os.X_OK): errors.append(f"{script.name}: not executable") result = subprocess.run(["bash", "-n", str(script)], capture_output=True, text=True) if result.returncode != 0: errors.append(f"{script.name}: syntax error — {result.stderr.strip()}") self._add( "Shell scripts executable & valid", len(errors) == 0, "; ".join(errors) if errors else f"All {len(scripts)} scripts OK", ) def caddyfile_well_formed(self): """Check Caddyfile has expected tokens.""" path = self.base_dir / "caddy" / "Caddyfile" if not path.exists(): self._add("Caddyfile well-formed", False, "File does not exist") return with open(path, "r") as f: content = f.read() has_reverse_proxy = "reverse_proxy" in content has_tls = "tls" in content.lower() or "acme" in content.lower() or "auto" in content.lower() has_well_known = ".well-known" in content or "matrix" in content.lower() ok = has_reverse_proxy and has_well_known detail = [] if not has_reverse_proxy: detail.append("missing reverse_proxy directive") if not has_well_known: detail.append("missing .well-known/matrix routing") self._add( "Caddyfile well-formed", ok, "Well-formed" if ok else f"Issues: {', '.join(detail)}", ) def runbook_links_valid(self): """Check docs/RUNBOOK.md has links to #166 and #183.""" path = self.base_dir / "docs" / "RUNBOOK.md" if not path.exists(): self._add("RUNBOOK.md issue links", False, "File does not exist") return with open(path, "r") as f: content = f.read() has_166 = "#166" in content or "166" in content has_183 = "#183" in content or "183" in content ok = has_166 and has_183 self._add( "RUNBOOK.md issue links", ok, "Links to #166 and #183" if ok else "Missing issue continuity links", ) def run_all(self): self.require_files() self.docker_compose_valid() self.conduit_toml_valid() self.env_example_complete() self.shell_scripts_executable() self.caddyfile_well_formed() self.runbook_links_valid() def report(self, json_mode: bool = False): if json_mode: print(json.dumps({ "base_dir": str(self.base_dir), "passed": self.passed, "failed": self.failed, "checks": self.checks, }, indent=2)) else: print(f"Matrix/Conduit Scaffold Validator") print(f"Base: {self.base_dir}") print(f"Checks: {self.passed} passed, {self.failed} failed\n") for c in self.checks: icon = "✅" if c["status"] == "PASS" else "❌" print(f"{icon} {c['name']:<40} {c['detail']}") print(f"\n{'SUCCESS' if self.failed == 0 else 'FAILURE'} — {self.passed}/{self.passed+self.failed} checks passed") def main(): parser = argparse.ArgumentParser(description="Validate Matrix/Conduit deployment scaffold") parser.add_argument("--json", action="store_true", help="Output JSON report") parser.add_argument("--base", default="infra/matrix", help="Path to scaffold directory") args = parser.parse_args() base = Path(args.base) if not base.exists(): # Try relative to script location script_dir = Path(__file__).resolve().parent base = script_dir.parent validator = Validator(base) validator.run_all() validator.report(json_mode=args.json) sys.exit(0 if validator.failed == 0 else 1) if __name__ == "__main__": main()