From 034b46361177c88dc7bdcf97bae540bee7b4bf30 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 05:01:45 -0400 Subject: [PATCH] Scope operations by selected protocol --- README.md | 6 ++ ampg/cli.py | 37 +++++++-- ampg/selection.py | 59 +++++++++++++ docs/daemon-management.md | 5 ++ tests/test_protocol_selection.py | 138 +++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 ampg/selection.py create mode 100644 tests/test_protocol_selection.py diff --git a/README.md b/README.md index 7f05abc..8e16796 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,10 @@ python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml status python3 -m ampg --config examples/wownero.gateway.toml doctor python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run +python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --protocol i2p python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml build +python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol i2p 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 @@ -127,6 +129,10 @@ that marker is present. Plan artifacts are written under `dist/ampg-plan/` only when `--write-artifacts` is passed. They are reviewable snippets, not installed daemon config. +Use `--protocol` to scope operational commands to one or more enabled protocols. This +lets a full site config build or activate only Tor, only I2P, or a selected subset without +unselected transports blocking the run. + ## Reusable checklist - [ ] Pick source kind: `static-html`, `markdown`, or `ssg-output`. diff --git a/ampg/cli.py b/ampg/cli.py index 265ff1d..4ee907f 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -24,6 +24,7 @@ from .route_manifest import ( validate_route_manifest, ) from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues +from .selection import parse_protocol_filters, select_protocols from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status @@ -37,6 +38,7 @@ def main(argv: list[str] | None = None) -> int: ) subcommands = parser.add_subparsers(dest="command", required=True) plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.") + _add_protocol_filter(plan_parser) plan_parser.add_argument( "--write-artifacts", action="store_true", @@ -46,6 +48,7 @@ def main(argv: list[str] | None = None) -> int: "status", help="Show enabled transport daemon decisions.", ) + _add_protocol_filter(status_parser) status_parser.add_argument( "--platform", choices=PLATFORM_NAMES, @@ -55,6 +58,7 @@ def main(argv: list[str] | None = None) -> int: "doctor", help="Check source, renderer, route, and daemon readiness.", ) + _add_protocol_filter(doctor_parser) doctor_parser.add_argument( "--platform", choices=PLATFORM_NAMES, @@ -64,6 +68,7 @@ def main(argv: list[str] | None = None) -> int: "apply", help="Show or run the transport activation sequence.", ) + _add_protocol_filter(apply_parser) apply_parser.add_argument( "--dry-run", action="store_true", @@ -79,8 +84,10 @@ def main(argv: list[str] | None = None) -> int: choices=PLATFORM_NAMES, help="Override platform detection for dry-run checks.", ) - subcommands.add_parser("build", help="Build enabled protocol outputs.") - subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") + build_parser = subcommands.add_parser("build", help="Build enabled protocol outputs.") + _add_protocol_filter(build_parser) + fixture_parser = subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") + _add_protocol_filter(fixture_parser) 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"): @@ -88,6 +95,7 @@ def main(argv: list[str] | None = None) -> int: preview_command, help=f"Preview {preview_command} for generated outputs.", ) + _add_protocol_filter(command_parser) command_parser.add_argument( "--base-port", type=int, @@ -101,11 +109,16 @@ def main(argv: list[str] | None = None) -> int: ) 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.") - routes_subcommands.add_parser( + routes_explain = routes_subcommands.add_parser( + "explain", + help="Print per-protocol route decisions.", + ) + _add_protocol_filter(routes_explain) + routes_validate = routes_subcommands.add_parser( "validate", help="Fail when a public route has no compatible enabled protocol.", ) + _add_protocol_filter(routes_validate) manifest_parser = subcommands.add_parser( "route-manifest", help="Validate or print the app route manifest contract.", @@ -150,7 +163,7 @@ def main(argv: list[str] | None = None) -> int: return _cmd_docs(args) if args.command == "route-manifest": return _cmd_route_manifest(args) - config = load_config(args.config) + config = _selected_config(load_config(args.config), args) if args.command == "plan": return _cmd_plan(config, write_artifacts=args.write_artifacts) if args.command == "status": @@ -205,6 +218,20 @@ def _cmd_apply(config, args) -> int: return 1 if blocked else 0 +def _add_protocol_filter(parser) -> None: + parser.add_argument( + "--protocol", + action="append", + default=[], + help="Limit this command to one protocol. May be repeated or comma-separated.", + ) + + +def _selected_config(config, args): + protocols = parse_protocol_filters(getattr(args, "protocol", None)) + return select_protocols(config, protocols) + + def _cmd_status(config, args) -> int: statuses = gateway_status(config, platform_provider=_platform_override(args)) for status in statuses: diff --git a/ampg/selection.py b/ampg/selection.py new file mode 100644 index 0000000..587cbb2 --- /dev/null +++ b/ampg/selection.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from .config import GatewayConfig, SiteConfig + + +def parse_protocol_filters(raw_filters: list[str] | tuple[str, ...] | None) -> tuple[str, ...]: + if not raw_filters: + return () + protocols: list[str] = [] + for raw_filter in raw_filters: + for name in raw_filter.split(","): + protocol = name.strip() + if protocol and protocol not in protocols: + protocols.append(protocol) + return tuple(protocols) + + +def select_protocols(config: GatewayConfig, protocols: tuple[str, ...]) -> GatewayConfig: + if not protocols: + return config + + selected = set(protocols) + declared = { + protocol_name + for site in config.sites + for protocol_name in site.protocols + } + missing = selected - declared + if missing: + names = ", ".join(sorted(missing)) + raise ValueError(f"selected protocol(s) not declared in config: {names}") + + enabled = { + protocol_name + for site in config.sites + for protocol_name, protocol in site.protocols.items() + if protocol.enabled + } + inactive = selected - enabled + if inactive: + names = ", ".join(sorted(inactive)) + raise ValueError(f"selected protocol(s) not enabled in config: {names}") + + sites = [ + SiteConfig( + id=site.id, + domain=site.domain, + source=site.source, + outputs=site.outputs, + protocols={ + name: protocol + for name, protocol in site.protocols.items() + if name in selected + }, + interactions=site.interactions, + ) + for site in config.sites + ] + return GatewayConfig(config_path=config.config_path, sites=sites) diff --git a/docs/daemon-management.md b/docs/daemon-management.md index b121402..897f951 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -40,6 +40,7 @@ config artifacts. python3 -m ampg --config gateway.toml status python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml apply --dry-run +python3 -m ampg --config gateway.toml apply --dry-run --protocol i2p python3 -m ampg --config gateway.toml apply --dry-run --write-artifacts python3 -m ampg --config gateway.toml doctor --platform android-termux ``` @@ -57,6 +58,10 @@ checks. It exits nonzero when any step is blocked. `--write-artifacts` writes re config snippets to the configured plan root but still does not install or reload services. +`--protocol` scopes operational commands to selected enabled protocols. A clearnet +`adopt` failure will block a full activation run, but it will not block +`apply --dry-run --protocol i2p` because clearnet is outside that selected run. + Platform providers describe how AMPG may supervise managed daemons: - `linux-systemd`: system service management after approval. diff --git a/tests/test_protocol_selection.py b/tests/test_protocol_selection.py new file mode 100644 index 0000000..60f1a34 --- /dev/null +++ b/tests/test_protocol_selection.py @@ -0,0 +1,138 @@ +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ampg.cli import main +from ampg.config import load_config +from ampg.selection import parse_protocol_filters, select_protocols + + +def _write_config(root: Path) -> Path: + source = root / "site" + source.mkdir() + (source / "index.html").write_text( + '

Hello

', + encoding="utf-8", + ) + (source / "app.js").write_text("boot()", 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" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +[site.protocols.clearnet] +enabled = true +renderer = "clearnet" +daemon = "nginx" +daemon_policy = "adopt" + +[site.protocols.tor] +enabled = true +renderer = "privacy-html" +daemon = "tor" +daemon_policy = "auto" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +""", + encoding="utf-8", + ) + return config_path + + +class ProtocolSelectionTest(unittest.TestCase): + def test_parse_protocol_filters_supports_repeated_and_comma_values(self): + self.assertEqual( + ("tor", "i2p", "gemini"), + parse_protocol_filters(["tor,i2p", "gemini", "tor"]), + ) + + def test_select_protocols_filters_sites(self): + with tempfile.TemporaryDirectory() as tmp: + config = load_config(_write_config(Path(tmp))) + selected = select_protocols(config, ("i2p",)) + + self.assertEqual(["i2p"], list(selected.sites[0].protocols)) + + def test_select_protocols_rejects_unknown_protocol(self): + with tempfile.TemporaryDirectory() as tmp: + config = load_config(_write_config(Path(tmp))) + + with self.assertRaisesRegex(ValueError, "not declared"): + select_protocols(config, ("gemini",)) + + def test_build_protocol_filter_only_writes_selected_output(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config_path = _write_config(root) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main(["--config", str(config_path), "build", "--protocol", "i2p"]) + + self.assertEqual(0, status) + self.assertTrue((root / "out/i2p/index.html").exists()) + self.assertFalse((root / "out/clearnet").exists()) + self.assertFalse((root / "out/tor").exists()) + self.assertIn("protocol=i2p", stdout.getvalue()) + self.assertNotIn("protocol=clearnet", stdout.getvalue()) + + def test_apply_protocol_filter_avoids_unselected_clearnet_block(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config_path = _write_config(root) + output = root / "out/i2p" + output.mkdir(parents=True) + (output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8") + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + [ + "--config", + str(config_path), + "apply", + "--dry-run", + "--protocol", + "i2p", + "--platform", + "android-termux", + ] + ) + + output_text = stdout.getvalue() + + self.assertEqual(0, status) + self.assertIn("protocol=i2p", output_text) + self.assertIn("blocked=0", output_text) + self.assertNotIn("protocol=clearnet", output_text) + + def test_cli_rejects_unknown_protocol_filter(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _write_config(Path(tmp)) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + status = main(["--config", str(config_path), "status", "--protocol", "gemini"]) + + self.assertEqual(1, status) + self.assertIn("selected protocol(s) not declared", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main()