Add local preview fixture manifests

This commit is contained in:
jwinterm
2026-07-06 04:24:30 -04:00
parent 38d86adfbf
commit 2e9307ba26
6 changed files with 372 additions and 0 deletions
+2
View File
@@ -103,6 +103,8 @@ python3 -m ampg --config examples/wownero.gateway.toml plan
python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts
python3 -m ampg --config examples/wownero.gateway.toml build
python3 -m ampg --config examples/wownero.gateway.toml manifest
python3 -m ampg --config examples/wownero.gateway.toml preview endpoints
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
python3 -m ampg --config examples/wownero.gateway.toml routes explain
python3 -m ampg --config examples/wownero.gateway.toml routes validate
python3 tools/generate_route_manifest.py examples/route-catalog.json examples/route-manifest.json
+74
View File
@@ -10,6 +10,7 @@ from .config import load_config
from .docsgen import generate_docs
from .manifest import write_fixture_manifests
from .plan import plan_gateway, write_plan_artifacts
from .preview import PreviewServers, preview_endpoints, write_preview_fixture_manifests
from .route_manifest import (
load_route_manifest,
route_manifest_schema_json,
@@ -35,6 +36,24 @@ def main(argv: list[str] | None = None) -> int:
)
subcommands.add_parser("build", help="Build enabled protocol outputs.")
subcommands.add_parser("manifest", help="Write AMPB fixture manifests.")
preview_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.")
preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True)
for preview_command in ("endpoints", "manifest", "serve"):
command_parser = preview_subcommands.add_parser(
preview_command,
help=f"Preview {preview_command} for generated outputs.",
)
command_parser.add_argument(
"--base-port",
type=int,
default=19080,
help="First loopback port assigned to enabled protocol outputs.",
)
command_parser.add_argument(
"--host",
default="127.0.0.1",
help="Loopback host for preview URLs.",
)
routes_parser = subcommands.add_parser("routes", help="Explain route exposure policy.")
routes_subcommands = routes_parser.add_subparsers(dest="routes_command", required=True)
routes_subcommands.add_parser("explain", help="Print per-protocol route decisions.")
@@ -93,6 +112,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_build(config)
if args.command == "manifest":
return _cmd_manifest(config)
if args.command == "preview":
return _cmd_preview(config, args)
if args.command == "routes":
return _cmd_routes(config, args)
if args.command == "audit":
@@ -166,6 +187,59 @@ def _cmd_manifest(config) -> int:
return 0
def _cmd_preview(config, args) -> int:
endpoints = preview_endpoints(config, base_port=args.base_port, host=args.host)
if args.preview_command == "endpoints":
for endpoint in endpoints:
_print_preview_endpoint(endpoint)
print(
"AMPG_PREVIEW_SUMMARY "
f"sites={len(config.sites)} endpoints={len(endpoints)}"
)
return 0
if args.preview_command == "manifest":
for endpoint in endpoints:
_print_preview_endpoint(endpoint)
for result in write_preview_fixture_manifests(
config,
base_port=args.base_port,
host=args.host,
):
print(
"AMPG_PREVIEW_MANIFEST "
f"site={result.site_id} "
f"path={result.path} "
f"fixtures={result.fixture_count}"
)
return 0
if args.preview_command == "serve":
for endpoint in endpoints:
_print_preview_endpoint(endpoint)
with PreviewServers(endpoints) as servers:
print(
"AMPG_PREVIEW_SERVE "
f"status=running endpoints={len(servers.endpoints)}"
)
servers.wait_forever()
return 0
return 1
def _print_preview_endpoint(endpoint) -> None:
print(
"AMPG_PREVIEW_ENDPOINT "
f"site={endpoint.site_id} "
f"protocol={endpoint.protocol} "
f"renderer={endpoint.renderer} "
f"url={endpoint.url} "
f"root={endpoint.root} "
f"status={endpoint.status}"
)
def _cmd_routes(config, args) -> int:
exposures = route_exposures(config)
issues = route_issues(config)
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
from contextlib import AbstractContextManager
from dataclasses import dataclass
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import threading
from typing import Any
from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .manifest import fixture_manifest
PREVIEW_MANIFEST_NAME = "ampg-preview-fixture-manifest.json"
@dataclass(frozen=True)
class PreviewEndpoint:
site_id: str
protocol: str
renderer: str
root: Path
host: str
port: int
url: str
status: str
@dataclass(frozen=True)
class PreviewManifestWriteResult:
site_id: str
path: Path
fixture_count: int
def preview_endpoints(
config: GatewayConfig,
*,
base_port: int = 19080,
host: str = "127.0.0.1",
) -> list[PreviewEndpoint]:
endpoints: list[PreviewEndpoint] = []
offset = 0
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
port = int(protocol.options.get("preview_port", base_port + offset))
root = site.outputs.root / protocol.name
status = "ready" if (root / ".ampg-output").exists() else "missing-output"
endpoints.append(
PreviewEndpoint(
site_id=site.id,
protocol=protocol.name,
renderer=protocol.renderer,
root=root,
host=host,
port=port,
url=f"http://{host}:{port}/",
status=status,
)
)
offset += 1
return endpoints
def preview_fixture_manifest(
site: SiteConfig,
endpoints: list[PreviewEndpoint],
) -> dict[str, Any]:
by_protocol = {
endpoint.protocol: endpoint
for endpoint in endpoints
if endpoint.site_id == site.id
}
manifest = fixture_manifest(site)
manifest["mode"] = "preview"
for fixture in manifest["fixtures"]:
endpoint = by_protocol[fixture["protocol"]]
published_url = fixture["url"]
published_checks = dict(fixture["checks"])
fixture_path = fixture.get("route", {}).get("fixture_path", "/")
fixture["published"] = {
"url": published_url,
"checks": published_checks,
"address_status": fixture["address_status"],
}
fixture["preview"] = {
"mode": "loopback-http",
"root": str(endpoint.root),
"status": endpoint.status,
}
fixture["url"] = _preview_url(endpoint.url, fixture_path)
fixture["address_status"] = "preview"
fixture["checks"] = {
"transport": "clearnet",
"profile": "clearnet",
}
return manifest
def write_preview_fixture_manifests(
config: GatewayConfig,
*,
base_port: int = 19080,
host: str = "127.0.0.1",
) -> list[PreviewManifestWriteResult]:
endpoints = preview_endpoints(config, base_port=base_port, host=host)
results: list[PreviewManifestWriteResult] = []
for site in config.sites:
manifest = preview_fixture_manifest(site, endpoints)
path = preview_manifest_path(site)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
results.append(
PreviewManifestWriteResult(
site_id=site.id,
path=path,
fixture_count=len(manifest["fixtures"]),
)
)
return results
def preview_manifest_path(site: SiteConfig) -> Path:
return site.outputs.root / PREVIEW_MANIFEST_NAME
class PreviewServers(AbstractContextManager):
def __init__(self, endpoints: list[PreviewEndpoint]) -> None:
self.endpoints = endpoints
self._servers: list[ThreadingHTTPServer] = []
self._threads: list[threading.Thread] = []
def __enter__(self) -> "PreviewServers":
for endpoint in self.endpoints:
if endpoint.status != "ready":
raise FileNotFoundError(
f"{endpoint.site_id}/{endpoint.protocol}: output root is not built: {endpoint.root}"
)
handler = partial(SimpleHTTPRequestHandler, directory=str(endpoint.root))
server = ThreadingHTTPServer((endpoint.host, endpoint.port), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self._servers.append(server)
self._threads.append(thread)
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
for server in self._servers:
server.shutdown()
server.server_close()
for thread in self._threads:
thread.join(timeout=2)
def wait_forever(self) -> None:
try:
threading.Event().wait()
except KeyboardInterrupt:
return
def _preview_url(base_url: str, fixture_path: str) -> str:
if not fixture_path or fixture_path == "/":
return base_url
return base_url.rstrip("/") + "/" + fixture_path.lstrip("/")
+12
View File
@@ -32,11 +32,23 @@ or deployment notes.
```sh
python3 -m ampg --config examples/wownero.gateway.toml build
python3 -m ampg --config examples/wownero.gateway.toml manifest
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
```
`build` emits the manifest after writing protocol outputs. `manifest` rewrites only the
manifest and does not rebuild protocol outputs.
`preview manifest` writes:
```text
<site.outputs.root>/ampg-preview-fixture-manifest.json
```
Preview manifests point fixture URLs at loopback HTTP servers for generated output roots.
They preserve the intended published URL and transport checks under each fixture's
`published` metadata. Use preview manifests to verify generated output with AMPB before
real Tor, I2P, Gemini, or other transport daemons are installed or adopted.
## Address Status
Some transports may not have final public addresses when a fixture is generated. In those
+4
View File
@@ -139,6 +139,8 @@ daemon_policy = "auto"
- `ampg plan` identifies the Wownero source tree and selected protocols.
- `ampg build` creates clearnet, Tor, I2P, and Gemini output without requiring Markdown.
- `ampg build` writes a fixture manifest that AMPB can check.
- `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live
transport daemons are installed or adopted.
- Tor output contains no `<script>` tags or inline event handlers.
- Generated Gemini output has readable headings, paragraphs, and links.
- Generated Micron output fits terminal-first browsing.
@@ -150,6 +152,8 @@ daemon_policy = "auto"
python3 -m ampg --config examples/wownero.gateway.toml plan
python3 -m ampg --config examples/wownero.gateway.toml build
python3 -m ampg --config examples/wownero.gateway.toml manifest
python3 -m ampg --config examples/wownero.gateway.toml preview endpoints
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
python3 -m ampg --config examples/i2p-only.gateway.toml plan
python3 -m ampg --config examples/i2p-only.gateway.toml build
```
+112
View File
@@ -0,0 +1,112 @@
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from ampg.build import build_gateway
from ampg.cli import main
from ampg.config import load_config
from ampg.preview import preview_endpoints, write_preview_fixture_manifests
class PreviewTest(unittest.TestCase):
def test_preview_manifest_rewrites_urls_to_loopback_and_preserves_published_metadata(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = _write_config(Path(tmp))
config = load_config(config_path)
build_gateway(config)
endpoints = preview_endpoints(config, base_port=19100)
self.assertEqual(["clearnet", "tor"], [endpoint.protocol for endpoint in endpoints])
self.assertEqual([19100, 19101], [endpoint.port for endpoint in endpoints])
self.assertEqual(["ready", "ready"], [endpoint.status for endpoint in endpoints])
results = write_preview_fixture_manifests(config, base_port=19100)
self.assertEqual(1, len(results))
self.assertEqual(4, results[0].fixture_count)
data = json.loads(results[0].path.read_text(encoding="utf-8"))
self.assertEqual("preview", data["mode"])
by_protocol_path = {
(fixture["protocol"], fixture.get("route", {}).get("fixture_path", "/")): fixture
for fixture in data["fixtures"]
}
tor_root = by_protocol_path[("tor", "/")]
self.assertEqual("http://127.0.0.1:19101/", tor_root["url"])
self.assertEqual({"transport": "clearnet", "profile": "clearnet"}, tor_root["checks"])
self.assertEqual("http://preview.onion/", tor_root["published"]["url"])
self.assertEqual({"transport": "tor", "profile": "tor"}, tor_root["published"]["checks"])
self.assertEqual("preview", tor_root["address_status"])
self.assertEqual("ready", tor_root["preview"]["status"])
tor_play = by_protocol_path[("tor", "/play/")]
self.assertEqual("http://127.0.0.1:19101/play/", tor_play["url"])
def test_preview_manifest_cli(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = _write_config(Path(tmp))
config = load_config(config_path)
build_gateway(config)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
status = main(["--config", str(config_path), "preview", "manifest", "--base-port", "19200"])
self.assertTrue((config.sites[0].outputs.root / "ampg-preview-fixture-manifest.json").exists())
output = stdout.getvalue()
self.assertEqual(0, status)
self.assertIn("AMPG_PREVIEW_ENDPOINT site=preview protocol=clearnet", output)
self.assertIn("AMPG_PREVIEW_ENDPOINT site=preview protocol=tor", output)
self.assertIn("AMPG_PREVIEW_MANIFEST site=preview", output)
def test_preview_endpoints_report_missing_outputs(self):
with tempfile.TemporaryDirectory() as tmp:
config = load_config(_write_config(Path(tmp)))
endpoints = preview_endpoints(config)
self.assertEqual(["missing-output", "missing-output"], [endpoint.status for endpoint in endpoints])
def _write_config(root: Path) -> Path:
source = root / "site"
source.mkdir()
(source / "index.html").write_text("<h1>Preview</h1>", encoding="utf-8")
config_path = root / "gateway.toml"
config_path.write_text(
"""
[[site]]
id = "preview"
domain = "preview.test"
[site.source]
kind = "static-html"
path = "./site"
canonical_url = "https://preview.test"
[site.outputs]
root = "./out"
plan_root = "./plan"
[[site.interactions.route]]
match = "/play/*"
tier = "interactive-lite"
[site.protocols.clearnet]
enabled = true
renderer = "clearnet"
[site.protocols.tor]
enabled = true
renderer = "privacy-html"
""",
encoding="utf-8",
)
return config_path
if __name__ == "__main__":
unittest.main()