mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 15:41:41 +02:00
Scope operations by selected protocol
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user