feat(matrix): scaffold validator + Hermes client spec
- Add validate-scaffold.py: automated acceptance proof for #183 - Add HERMES_MATRIX_CLIENT_SPEC.md: end-to-end agent integration spec for #166 Refs #183, #166
This commit is contained in:
0
infra/matrix/deploy-matrix.sh
Normal file → Executable file
0
infra/matrix/deploy-matrix.sh
Normal file → Executable file
0
infra/matrix/host-readiness-check.sh
Normal file → Executable file
0
infra/matrix/host-readiness-check.sh
Normal file → Executable file
0
infra/matrix/scripts/deploy-conduit.sh
Normal file → Executable file
0
infra/matrix/scripts/deploy-conduit.sh
Normal file → Executable file
236
infra/matrix/scripts/validate-scaffold.py
Executable file
236
infra/matrix/scripts/validate-scaffold.py
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user