Emit AMPB fixture manifests

This commit is contained in:
jwinterm
2026-07-05 21:16:06 -04:00
parent 5c924cae60
commit 717c8fb9c5
11 changed files with 346 additions and 2 deletions
+3
View File
@@ -78,6 +78,7 @@ internal even when the public site is available on Tor or I2P.
- [Architecture](docs/architecture.md)
- [Configuration](docs/configuration.md)
- [Daemon management](docs/daemon-management.md)
- [Fixture manifests](docs/fixture-manifests.md)
- [Interactive applications](docs/interactive-apps.md)
- [Wownero integration](docs/wownero-integration.md)
- [Autodocs and drift gates](docs/autodocs.md)
@@ -97,6 +98,7 @@ The current implementation is dependency-free Python:
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 audit
python3 -m ampg --config examples/i2p-only.gateway.toml build
python3 -m ampg docs generate
@@ -116,4 +118,5 @@ passed. They are reviewable snippets, not installed daemon config.
- [ ] Select protocols and daemon policy.
- [ ] Run `ampg plan`; review output roots, ports, and config changes.
- [ ] Run `ampg build`; inspect generated variants.
- [ ] Run AMPB fixture checks against generated manifests.
- [ ] Run `ampg apply`; publish through adopted or managed daemons.
+22
View File
@@ -8,6 +8,7 @@ from .audit import audit_gateway
from .build import build_gateway
from .config import load_config
from .docsgen import generate_docs
from .manifest import write_fixture_manifests
from .plan import plan_gateway, write_plan_artifacts
@@ -27,6 +28,7 @@ def main(argv: list[str] | None = None) -> int:
help="Write generated config snippets to the configured plan root.",
)
subcommands.add_parser("build", help="Build enabled protocol outputs.")
subcommands.add_parser("manifest", help="Write AMPB fixture manifests.")
audit_parser = subcommands.add_parser("audit", help="Audit source HTML quality.")
audit_parser.add_argument(
"--fail-on-warn",
@@ -52,6 +54,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_plan(config, write_artifacts=args.write_artifacts)
if args.command == "build":
return _cmd_build(config)
if args.command == "manifest":
return _cmd_manifest(config)
if args.command == "audit":
return _cmd_audit(config, fail_on_warn=args.fail_on_warn)
except Exception as exc: # noqa: BLE001 - CLI should print concise failures.
@@ -102,6 +106,24 @@ def _cmd_build(config) -> int:
f" skipped={result.files_skipped}"
f"{extra}"
)
for manifest in write_fixture_manifests(config):
print(
"AMPG_MANIFEST "
f"site={manifest.site_id} "
f"path={manifest.path} "
f"fixtures={manifest.fixture_count}"
)
return 0
def _cmd_manifest(config) -> int:
for manifest in write_fixture_manifests(config):
print(
"AMPG_MANIFEST "
f"site={manifest.site_id} "
f"path={manifest.path} "
f"fixtures={manifest.fixture_count}"
)
return 0
+2
View File
@@ -12,6 +12,7 @@ DEFAULT_DAEMONS = {
"i2p": "i2pd",
"gemini": "agate",
"reticulum": "rnsd",
"ipfs": "ipfs",
}
DEFAULT_POLICIES = {
@@ -20,6 +21,7 @@ DEFAULT_POLICIES = {
"i2p": "auto",
"gemini": "auto",
"reticulum": "auto",
"ipfs": "auto",
}
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import Any
from .config import GatewayConfig, ProtocolConfig, SiteConfig
SCHEMA = "ampg.fixture-manifest.v1"
@dataclass(frozen=True)
class ManifestWriteResult:
site_id: str
path: Path
fixture_count: int
def fixture_manifest(site: SiteConfig) -> dict[str, Any]:
fixtures = [
_fixture_entry(site, protocol)
for protocol in site.protocols.values()
if protocol.enabled
]
return {
"schema": SCHEMA,
"site": {
"id": site.id,
"domain": site.domain,
"canonical_url": site.source.canonical_url or "",
},
"fixtures": fixtures,
}
def write_fixture_manifests(config: GatewayConfig) -> list[ManifestWriteResult]:
results: list[ManifestWriteResult] = []
for site in config.sites:
manifest = fixture_manifest(site)
path = 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(
ManifestWriteResult(
site_id=site.id,
path=path,
fixture_count=len(manifest["fixtures"]),
)
)
return results
def manifest_path(site: SiteConfig) -> Path:
return site.outputs.root / "ampg-fixture-manifest.json"
def _fixture_entry(site: SiteConfig, protocol: ProtocolConfig) -> dict[str, Any]:
url, address_status = _fixture_url(site, protocol)
transport = _transport_for(protocol.name)
return {
"protocol": protocol.name,
"renderer": protocol.renderer,
"url": url,
"address_status": address_status,
"output_path": protocol.name,
"checks": {
"transport": transport,
"profile": transport,
},
}
def _transport_for(protocol_name: str) -> str:
if protocol_name in {"clearnet", "tor", "i2p", "gemini", "reticulum", "ipfs"}:
return protocol_name
return protocol_name
def _fixture_url(site: SiteConfig, protocol: ProtocolConfig) -> tuple[str, str]:
configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url")
if configured_url:
return _with_trailing_slash(str(configured_url)), "configured"
if protocol.name == "clearnet":
if site.source.canonical_url:
return _with_trailing_slash(site.source.canonical_url), "configured"
return f"https://{site.domain}/", "derived"
if protocol.name == "tor":
onion_url = protocol.options.get("onion_url")
if onion_url:
return _with_trailing_slash(str(onion_url)), "configured"
onion_location = str(protocol.options.get("onion_location", "auto"))
if onion_location and onion_location != "auto":
return _with_scheme(onion_location, "http"), "configured"
return f"http://{site.id}.onion/", "placeholder"
if protocol.name == "i2p":
i2p_url = protocol.options.get("i2p_url") or protocol.options.get("i2p_hostname")
if i2p_url:
return _with_scheme(str(i2p_url), "http"), "configured"
return f"http://{site.id}.i2p/", "placeholder"
if protocol.name == "gemini":
gemini_url = protocol.options.get("gemini_url")
if gemini_url:
return _with_scheme(str(gemini_url), "gemini"), "configured"
return f"gemini://{site.domain}/", "derived"
if protocol.name == "reticulum":
rns_url = protocol.options.get("rns_url")
if rns_url:
return _with_scheme(str(rns_url), "rns"), "configured"
return f"rns://{site.id}", "placeholder"
if protocol.name == "ipfs":
ipfs_url = protocol.options.get("ipfs_url")
if ipfs_url:
return _with_scheme(str(ipfs_url), "ipfs"), "configured"
cid = protocol.options.get("cid")
if cid:
return f"ipfs://{cid}", "configured"
return f"/ipfs/{site.id}", "placeholder"
return f"https://{site.domain}/", "derived"
def _with_scheme(value: str, scheme: str) -> str:
if "://" in value:
return _with_trailing_slash(value)
return _with_trailing_slash(f"{scheme}://{value}")
def _with_trailing_slash(value: str) -> str:
if value.startswith(("rns://", "lxmf://", "nomad://", "ipfs://", "ipns://")):
return value
if value.endswith("/"):
return value
return value + "/"
+10
View File
@@ -188,4 +188,14 @@ DAEMON_ADAPTERS = (
"Certificate and key paths are plan values until install/apply support exists.",
),
),
DaemonAdapter(
daemon=DEFAULT_DAEMONS["ipfs"],
protocols=("ipfs",),
default_policy=DEFAULT_POLICIES["ipfs"],
generated_artifacts=("fixture manifest route expectations",),
notes=(
"IPFS output is static web content for a local gateway or later pinning.",
"IPFS is content-addressed distribution, not an anonymity layer.",
),
),
)
+6 -1
View File
@@ -15,7 +15,7 @@ source site
-> render profiles
-> output roots
-> daemon adapters
-> clearnet / Tor / I2P / Gemini / Reticulum
-> clearnet / Tor / I2P / Gemini / IPFS / Reticulum
```
## Source adapters
@@ -69,6 +69,10 @@ own output.
: Gemini output. Converts headings, paragraphs, lists, code blocks, and links to `.gmi`.
Inline links become block links near the paragraph by default.
`clearnet` for IPFS
: Static web output suitable for a local IPFS gateway or later pinning. IPFS is treated
as content-addressed distribution, not an anonymity layer.
`micron`
: Reticulum/Nomad output. Produces terminal-oriented Micron markup with small pages,
plain links, and no heavy media by default.
@@ -82,6 +86,7 @@ Daemon adapters provide a common interface:
- `render_config`: produce config snippets and complete managed configs.
- `apply`: write approved managed config and start or reload services.
- `health`: verify the selected transport can serve the generated output.
- `manifest`: emit AMPB route/profile expectations for generated fixtures.
Adapters must be conservative. They do not modify non-AMPG config without showing a
plan first, and managed config lives under AMPG-owned state directories.
+12 -1
View File
@@ -66,6 +66,13 @@ daemon_policy = "auto"
port = 1965
max_asset_bytes = 1048576
[site.protocols.ipfs]
enabled = false
renderer = "clearnet"
daemon = "ipfs"
daemon_policy = "auto"
cid = ""
[site.protocols.reticulum]
enabled = false
renderer = "micron"
@@ -86,6 +93,7 @@ workflow.
| tor | `privacy-html` | `tor` | `auto` |
| i2p | `privacy-html` | `i2pd` | `auto` |
| gemini | `gemtext` | `agate` | `auto` |
| ipfs | `clearnet` | `ipfs` | `auto` |
| reticulum | `micron` | `rnsd` | `auto` |
## Renderer defaults
@@ -178,7 +186,7 @@ omits it or renders a safe alternate page.
## Protocol-only deployments
Enabled protocols are independent. A config can omit clearnet entirely and publish only
to I2P, Tor, Gemini, Reticulum, or any combination of supported targets.
to I2P, Tor, Gemini, IPFS, Reticulum, or any combination of supported targets.
For an existing HTML site that should publish only to I2P:
@@ -209,6 +217,9 @@ script_policy = "strip"
This builds only the I2P output root and only the I2P/nginx plan snippets.
For IPFS, use the `clearnet` renderer to create a static output tree that can be served
through a local gateway or pinned by a later publishing step.
## Plan artifacts
`ampg plan` prints expected artifact paths. `ampg plan --write-artifacts` writes
+41
View File
@@ -0,0 +1,41 @@
# Fixture Manifests
> Status: draft | Updated 2026-07-06 | Applies to: AMPG and AMPB compatibility checks
AMPG writes deterministic fixture manifests so AMPB can verify that generated site
variants route to the expected browser transport and profile.
## Output
`ampg build` writes one manifest per site:
```text
<site.outputs.root>/ampg-fixture-manifest.json
```
The manifest contains public route expectations only:
- site id, domain, and canonical URL.
- enabled protocol fixtures.
- protocol renderer and relative output path.
- route URL for AMPB checks.
- expected AMPB transport and profile.
It must not contain private host inventory, hidden-service keys, tunnel keys, credentials,
or deployment notes.
## Commands
```sh
python3 -m ampg --config examples/wownero.gateway.toml build
python3 -m ampg --config examples/wownero.gateway.toml manifest
```
`build` emits the manifest after writing protocol outputs. `manifest` rewrites only the
manifest and does not rebuild protocol outputs.
## Address Status
Some transports may not have final public addresses when a fixture is generated. In those
cases AMPG uses route-valid placeholders such as `http://wownero.onion/` so AMPB can still
verify transport selection without contacting the network.
+10
View File
@@ -44,3 +44,13 @@ Notes:
Notes:
- Gemini serves generated Gemtext output directly.
- Certificate and key paths are plan values until install/apply support exists.
## ipfs
- Protocols: ipfs
- Default policy: `auto`
- Generated artifacts: fixture manifest route expectations
Notes:
- IPFS output is static web content for a local gateway or later pinning.
- IPFS is content-addressed distribution, not an anonymity layer.
+17
View File
@@ -15,6 +15,7 @@ transport-specific variants with defaults:
- Tor: privacy-hardened static HTML.
- I2P: privacy-hardened static HTML.
- Gemini: text-first `.gmi` pages.
- IPFS: static output tree for gateway or pinning workflows.
- Reticulum: compact Micron `.mu` pages.
## Source Adapter
@@ -51,6 +52,10 @@ must not be required for AMPG adoption.
index.gmi
mining.gmi
emission.gmi
ipfs/
index.html
css/
img/
reticulum/
index.mu
mining.mu
@@ -84,6 +89,11 @@ Use `gemtext`:
- convert large media to plain download links.
- skip repeated navigation noise.
### IPFS
Use `clearnet` static output for content-addressed publishing. AMPG emits route
expectations for AMPB, but pinning and CID publication remain explicit operator actions.
### Reticulum
Use `micron`:
@@ -114,6 +124,11 @@ daemon_policy = "auto"
enabled = false
daemon_policy = "auto"
[site.protocols.ipfs]
enabled = false
renderer = "clearnet"
daemon_policy = "auto"
[site.protocols.reticulum]
enabled = false
daemon_policy = "auto"
@@ -123,6 +138,7 @@ 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.
- 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.
@@ -133,6 +149,7 @@ daemon_policy = "auto"
```sh
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/i2p-only.gateway.toml plan
python3 -m ampg --config examples/i2p-only.gateway.toml build
```
+79
View File
@@ -0,0 +1,79 @@
import json
import tempfile
import unittest
from pathlib import Path
from ampg.config import load_config
from ampg.manifest import fixture_manifest, write_fixture_manifests
class FixtureManifestTest(unittest.TestCase):
def test_manifest_contains_route_checks_for_enabled_protocols(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "site"
source.mkdir()
(source / "index.html").write_text("<h1>Hi</h1>", encoding="utf-8")
config_path = root / "gateway.toml"
config_path.write_text(
"""
[[site]]
id = "example"
domain = "example.test"
[site.source]
kind = "static-html"
path = "./site"
canonical_url = "https://example.test"
[site.outputs]
root = "./out"
plan_root = "./plan"
[site.protocols.clearnet]
enabled = true
renderer = "clearnet"
[site.protocols.tor]
enabled = true
renderer = "privacy-html"
onion_location = "auto"
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
[site.protocols.gemini]
enabled = true
renderer = "gemtext"
[site.protocols.ipfs]
enabled = true
renderer = "clearnet"
cid = "bafyexample"
""",
encoding="utf-8",
)
config = load_config(config_path)
manifest = fixture_manifest(config.sites[0])
written = write_fixture_manifests(config)
self.assertEqual("ampg.fixture-manifest.v1", manifest["schema"])
self.assertEqual(["example"], [result.site_id for result in written])
self.assertEqual(5, written[0].fixture_count)
path = root / "out/ampg-fixture-manifest.json"
data = json.loads(path.read_text(encoding="utf-8"))
by_protocol = {fixture["protocol"]: fixture for fixture in data["fixtures"]}
self.assertEqual("https://example.test/", by_protocol["clearnet"]["url"])
self.assertEqual("http://example.onion/", by_protocol["tor"]["url"])
self.assertEqual("placeholder", by_protocol["tor"]["address_status"])
self.assertEqual("gemini://example.test/", by_protocol["gemini"]["url"])
self.assertEqual("ipfs://bafyexample", by_protocol["ipfs"]["url"])
self.assertEqual({"transport": "ipfs", "profile": "ipfs"}, by_protocol["ipfs"]["checks"])
if __name__ == "__main__":
unittest.main()