From 2e9307ba2678f4d8c43c6854a74727e4f7e02205 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 04:24:30 -0400 Subject: [PATCH] Add local preview fixture manifests --- README.md | 2 + ampg/cli.py | 74 ++++++++++++++++ ampg/preview.py | 168 ++++++++++++++++++++++++++++++++++++ docs/fixture-manifests.md | 12 +++ docs/wownero-integration.md | 4 + tests/test_preview.py | 112 ++++++++++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 ampg/preview.py create mode 100644 tests/test_preview.py diff --git a/README.md b/README.md index 7ceb099..0987b21 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ampg/cli.py b/ampg/cli.py index 4622493..bec13b2 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -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) diff --git a/ampg/preview.py b/ampg/preview.py new file mode 100644 index 0000000..827b3ae --- /dev/null +++ b/ampg/preview.py @@ -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("/") diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index c6b0e65..ac2b9d7 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -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 +/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 diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index 1d3cccc..44b21e7 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -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 `