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( + '