mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Emit AMPB fixture manifests
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 + "/"
|
||||
@@ -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.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user