Scope operations by selected protocol

This commit is contained in:
jwinterm
2026-07-06 05:01:45 -04:00
parent 83847b205b
commit 034b463611
5 changed files with 240 additions and 5 deletions
+6
View File
@@ -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 status
python3 -m ampg --config examples/wownero.gateway.toml doctor 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
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 apply --dry-run --write-artifacts
python3 -m ampg --config examples/wownero.gateway.toml build 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 manifest
python3 -m ampg --config examples/wownero.gateway.toml preview endpoints 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 preview manifest
@@ -127,6 +129,10 @@ that marker is present.
Plan artifacts are written under `dist/ampg-plan/` only when `--write-artifacts` is Plan artifacts are written under `dist/ampg-plan/` only when `--write-artifacts` is
passed. They are reviewable snippets, not installed daemon config. 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 ## Reusable checklist
- [ ] Pick source kind: `static-html`, `markdown`, or `ssg-output`. - [ ] Pick source kind: `static-html`, `markdown`, or `ssg-output`.
+32 -5
View File
@@ -24,6 +24,7 @@ from .route_manifest import (
validate_route_manifest, validate_route_manifest,
) )
from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues 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 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) subcommands = parser.add_subparsers(dest="command", required=True)
plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.") plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.")
_add_protocol_filter(plan_parser)
plan_parser.add_argument( plan_parser.add_argument(
"--write-artifacts", "--write-artifacts",
action="store_true", action="store_true",
@@ -46,6 +48,7 @@ def main(argv: list[str] | None = None) -> int:
"status", "status",
help="Show enabled transport daemon decisions.", help="Show enabled transport daemon decisions.",
) )
_add_protocol_filter(status_parser)
status_parser.add_argument( status_parser.add_argument(
"--platform", "--platform",
choices=PLATFORM_NAMES, choices=PLATFORM_NAMES,
@@ -55,6 +58,7 @@ def main(argv: list[str] | None = None) -> int:
"doctor", "doctor",
help="Check source, renderer, route, and daemon readiness.", help="Check source, renderer, route, and daemon readiness.",
) )
_add_protocol_filter(doctor_parser)
doctor_parser.add_argument( doctor_parser.add_argument(
"--platform", "--platform",
choices=PLATFORM_NAMES, choices=PLATFORM_NAMES,
@@ -64,6 +68,7 @@ def main(argv: list[str] | None = None) -> int:
"apply", "apply",
help="Show or run the transport activation sequence.", help="Show or run the transport activation sequence.",
) )
_add_protocol_filter(apply_parser)
apply_parser.add_argument( apply_parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
@@ -79,8 +84,10 @@ def main(argv: list[str] | None = None) -> int:
choices=PLATFORM_NAMES, choices=PLATFORM_NAMES,
help="Override platform detection for dry-run checks.", help="Override platform detection for dry-run checks.",
) )
subcommands.add_parser("build", help="Build enabled protocol outputs.") build_parser = subcommands.add_parser("build", help="Build enabled protocol outputs.")
subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") _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_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.")
preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True) preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True)
for preview_command in ("endpoints", "manifest", "serve"): for preview_command in ("endpoints", "manifest", "serve"):
@@ -88,6 +95,7 @@ def main(argv: list[str] | None = None) -> int:
preview_command, preview_command,
help=f"Preview {preview_command} for generated outputs.", help=f"Preview {preview_command} for generated outputs.",
) )
_add_protocol_filter(command_parser)
command_parser.add_argument( command_parser.add_argument(
"--base-port", "--base-port",
type=int, 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_parser = subcommands.add_parser("routes", help="Explain route exposure policy.")
routes_subcommands = routes_parser.add_subparsers(dest="routes_command", required=True) routes_subcommands = routes_parser.add_subparsers(dest="routes_command", required=True)
routes_subcommands.add_parser("explain", help="Print per-protocol route decisions.") routes_explain = routes_subcommands.add_parser(
routes_subcommands.add_parser( "explain",
help="Print per-protocol route decisions.",
)
_add_protocol_filter(routes_explain)
routes_validate = routes_subcommands.add_parser(
"validate", "validate",
help="Fail when a public route has no compatible enabled protocol.", help="Fail when a public route has no compatible enabled protocol.",
) )
_add_protocol_filter(routes_validate)
manifest_parser = subcommands.add_parser( manifest_parser = subcommands.add_parser(
"route-manifest", "route-manifest",
help="Validate or print the app route manifest contract.", 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) return _cmd_docs(args)
if args.command == "route-manifest": if args.command == "route-manifest":
return _cmd_route_manifest(args) return _cmd_route_manifest(args)
config = load_config(args.config) config = _selected_config(load_config(args.config), args)
if args.command == "plan": if args.command == "plan":
return _cmd_plan(config, write_artifacts=args.write_artifacts) return _cmd_plan(config, write_artifacts=args.write_artifacts)
if args.command == "status": if args.command == "status":
@@ -205,6 +218,20 @@ def _cmd_apply(config, args) -> int:
return 1 if blocked else 0 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: def _cmd_status(config, args) -> int:
statuses = gateway_status(config, platform_provider=_platform_override(args)) statuses = gateway_status(config, platform_provider=_platform_override(args))
for status in statuses: for status in statuses:
+59
View File
@@ -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)
+5
View File
@@ -40,6 +40,7 @@ config artifacts.
python3 -m ampg --config gateway.toml status python3 -m ampg --config gateway.toml status
python3 -m ampg --config gateway.toml doctor 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
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 apply --dry-run --write-artifacts
python3 -m ampg --config gateway.toml doctor --platform android-termux 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 config snippets to the configured plan root but still does not install or reload
services. 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: Platform providers describe how AMPG may supervise managed daemons:
- `linux-systemd`: system service management after approval. - `linux-systemd`: system service management after approval.
+138
View File
@@ -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(
'<html><body onload="boot()"><script src="app.js"></script><h1>Hello</h1></body></html>',
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()