diff --git a/README.md b/README.md index 8e16796..8a789cf 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,11 @@ 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 --profile mobile-i2p 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 --profile tor-i2p 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 @@ -133,6 +135,10 @@ Use `--protocol` to scope operational commands to one or more enabled protocols. lets a full site config build or activate only Tor, only I2P, or a selected subset without unselected transports blocking the run. +Use `--profile` to load a named deployment target from `gateway.toml`. Profiles can set +selected protocols, a default platform provider, and safe artifact-writing defaults. +Explicit `--protocol` and `--platform` flags override the profile when present. + ## Reusable checklist - [ ] Pick source kind: `static-html`, `markdown`, or `ssg-output`. diff --git a/ampg/cli.py b/ampg/cli.py index 4ee907f..19589f9 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -24,7 +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 .selection import protocols_for_selection, select_profile, select_protocols from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status @@ -38,7 +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) + _add_target_selection(plan_parser) plan_parser.add_argument( "--write-artifacts", action="store_true", @@ -48,7 +48,7 @@ def main(argv: list[str] | None = None) -> int: "status", help="Show enabled transport daemon decisions.", ) - _add_protocol_filter(status_parser) + _add_target_selection(status_parser) status_parser.add_argument( "--platform", choices=PLATFORM_NAMES, @@ -58,7 +58,7 @@ def main(argv: list[str] | None = None) -> int: "doctor", help="Check source, renderer, route, and daemon readiness.", ) - _add_protocol_filter(doctor_parser) + _add_target_selection(doctor_parser) doctor_parser.add_argument( "--platform", choices=PLATFORM_NAMES, @@ -68,7 +68,7 @@ def main(argv: list[str] | None = None) -> int: "apply", help="Show or run the transport activation sequence.", ) - _add_protocol_filter(apply_parser) + _add_target_selection(apply_parser) apply_parser.add_argument( "--dry-run", action="store_true", @@ -85,9 +85,9 @@ def main(argv: list[str] | None = None) -> int: help="Override platform detection for dry-run checks.", ) build_parser = subcommands.add_parser("build", help="Build enabled protocol outputs.") - _add_protocol_filter(build_parser) + _add_target_selection(build_parser) fixture_parser = subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") - _add_protocol_filter(fixture_parser) + _add_target_selection(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"): @@ -95,7 +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) + _add_target_selection(command_parser) command_parser.add_argument( "--base-port", type=int, @@ -113,12 +113,12 @@ def main(argv: list[str] | None = None) -> int: "explain", help="Print per-protocol route decisions.", ) - _add_protocol_filter(routes_explain) + _add_target_selection(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) + _add_target_selection(routes_validate) manifest_parser = subcommands.add_parser( "route-manifest", help="Validate or print the app route manifest contract.", @@ -165,7 +165,7 @@ def main(argv: list[str] | None = None) -> int: return _cmd_route_manifest(args) config = _selected_config(load_config(args.config), args) if args.command == "plan": - return _cmd_plan(config, write_artifacts=args.write_artifacts) + return _cmd_plan(config, write_artifacts=_write_artifacts_enabled(config, args)) if args.command == "status": return _cmd_status(config, args) if args.command == "doctor": @@ -196,11 +196,11 @@ def _cmd_apply(config, args) -> int: ) return 1 - if args.write_artifacts: + if _write_artifacts_enabled(config, args): for path in write_activation_artifacts(config): print(f"AMPG_APPLY_ARTIFACT path={path} status=written") - steps = activation_steps(config, platform_provider=_platform_override(args)) + steps = activation_steps(config, platform_provider=_platform_override(config, args)) for step in steps: _print_activation_step(step) @@ -218,7 +218,11 @@ def _cmd_apply(config, args) -> int: return 1 if blocked else 0 -def _add_protocol_filter(parser) -> None: +def _add_target_selection(parser) -> None: + parser.add_argument( + "--profile", + help="Named deployment profile from gateway config.", + ) parser.add_argument( "--protocol", action="append", @@ -228,12 +232,23 @@ def _add_protocol_filter(parser) -> None: def _selected_config(config, args): - protocols = parse_protocol_filters(getattr(args, "protocol", None)) + protocols = protocols_for_selection( + config, + raw_protocols=getattr(args, "protocol", None), + profile_name=getattr(args, "profile", None), + ) return select_protocols(config, protocols) +def _write_artifacts_enabled(config, args) -> bool: + if getattr(args, "write_artifacts", False): + return True + profile = select_profile(config, getattr(args, "profile", None)) + return bool(profile and profile.write_artifacts) + + def _cmd_status(config, args) -> int: - statuses = gateway_status(config, platform_provider=_platform_override(args)) + statuses = gateway_status(config, platform_provider=_platform_override(config, args)) for status in statuses: _print_transport_status(status) print( @@ -248,7 +263,7 @@ def _cmd_status(config, args) -> int: def _cmd_doctor(config, args) -> int: - platform_provider = _platform_override(args) + platform_provider = _platform_override(config, args) statuses = gateway_status(config, platform_provider=platform_provider) issues = doctor_gateway(config, platform_provider=platform_provider, statuses=statuses) for status in statuses: @@ -559,9 +574,12 @@ def _cmd_docs(args) -> int: return 1 -def _platform_override(args): +def _platform_override(config, args): if getattr(args, "platform", None): return platform_by_name(args.platform) + profile = select_profile(config, getattr(args, "profile", None)) + if profile and profile.platform: + return platform_by_name(profile.platform) return None diff --git a/ampg/config.py b/ampg/config.py index 9e501b2..ec34a6d 100644 --- a/ampg/config.py +++ b/ampg/config.py @@ -78,10 +78,20 @@ class SiteConfig: interactions: InteractionConfig +@dataclass(frozen=True) +class ProfileConfig: + name: str + protocols: tuple[str, ...] = () + platform: str | None = None + write_artifacts: bool = False + description: str = "" + + @dataclass(frozen=True) class GatewayConfig: config_path: Path sites: list[SiteConfig] + profiles: dict[str, ProfileConfig] = field(default_factory=dict) def load_config(path: Path) -> GatewayConfig: @@ -90,7 +100,36 @@ def load_config(path: Path) -> GatewayConfig: sites = [_parse_site(raw_site, config_path.parent) for raw_site in data.get("site", [])] if not sites: raise ValueError("config must declare at least one [[site]]") - return GatewayConfig(config_path=config_path, sites=sites) + profiles = _parse_profiles(data.get("profiles", {})) + return GatewayConfig(config_path=config_path, sites=sites, profiles=profiles) + + +def _parse_profiles(raw_profiles: Any) -> dict[str, ProfileConfig]: + if raw_profiles is None: + return {} + if not isinstance(raw_profiles, dict): + raise ValueError("profiles must be a table") + profiles: dict[str, ProfileConfig] = {} + for name, raw_profile in raw_profiles.items(): + if not isinstance(raw_profile, dict): + raise ValueError(f"profile {name!r} must be a table") + platform = raw_profile.get("platform") + if platform is not None and not isinstance(platform, str): + raise ValueError(f"profile {name!r}.platform must be a string") + description = raw_profile.get("description", "") + if not isinstance(description, str): + raise ValueError(f"profile {name!r}.description must be a string") + profiles[str(name)] = ProfileConfig( + name=str(name), + protocols=_string_array( + raw_profile.get("protocols", ()), + f"profile {name!r}.protocols", + ), + platform=platform, + write_artifacts=bool(raw_profile.get("write_artifacts", False)), + description=description, + ) + return profiles def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig: diff --git a/ampg/metadata.py b/ampg/metadata.py index a6d2520..dae8e77 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -203,6 +203,38 @@ CONFIG_FIELDS = ( "transport default", "Maximum interaction tier this protocol target may expose.", ), + ConfigField( + "profiles.", + "description", + "string", + False, + "", + "Human-readable deployment profile description.", + ), + ConfigField( + "profiles.", + "protocols", + "array", + False, + "all enabled protocols", + "Enabled protocols selected by this deployment profile.", + ), + ConfigField( + "profiles.", + "platform", + "enum", + False, + "detected platform", + "Platform provider used by status, doctor, and apply when not overridden.", + ), + ConfigField( + "profiles.", + "write_artifacts", + "boolean", + False, + "false", + "Whether plan/apply dry-run writes review artifacts by default.", + ), ) diff --git a/ampg/selection.py b/ampg/selection.py index 587cbb2..bf853b3 100644 --- a/ampg/selection.py +++ b/ampg/selection.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .config import GatewayConfig, SiteConfig +from .config import GatewayConfig, ProfileConfig, SiteConfig def parse_protocol_filters(raw_filters: list[str] | tuple[str, ...] | None) -> tuple[str, ...]: @@ -56,4 +56,33 @@ def select_protocols(config: GatewayConfig, protocols: tuple[str, ...]) -> Gatew ) for site in config.sites ] - return GatewayConfig(config_path=config.config_path, sites=sites) + return GatewayConfig( + config_path=config.config_path, + sites=sites, + profiles=config.profiles, + ) + + +def select_profile(config: GatewayConfig, profile_name: str | None) -> ProfileConfig | None: + if not profile_name: + return None + profile = config.profiles.get(profile_name) + if profile is None: + names = ", ".join(sorted(config.profiles)) or "-" + raise ValueError(f"unknown profile {profile_name!r}; available profiles: {names}") + return profile + + +def protocols_for_selection( + config: GatewayConfig, + *, + raw_protocols: list[str] | tuple[str, ...] | None, + profile_name: str | None, +) -> tuple[str, ...]: + protocols = parse_protocol_filters(raw_protocols) + if protocols: + return protocols + profile = select_profile(config, profile_name) + if profile: + return profile.protocols + return () diff --git a/docs/configuration.md b/docs/configuration.md index 9e0b80b..60c1d1e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,7 @@ public_allowed = false [site.protocols.clearnet] enabled = true renderer = "clearnet" -daemon = "caddy" +daemon = "nginx" daemon_policy = "adopt" [site.protocols.tor] @@ -100,7 +100,7 @@ workflow. | Protocol | Default renderer | Default daemon | Default policy | | --- | --- | --- | --- | -| clearnet | `clearnet` | `caddy` | `adopt` | +| clearnet | `clearnet` | `nginx` | `adopt` | | tor | `privacy-html` | `tor` | `auto` | | i2p | `privacy-html` | `i2pd` | `auto` | | gemini | `gemtext` | `agate` | `auto` | @@ -246,6 +246,33 @@ not belong in it. Enabled protocols are independent. A config can omit clearnet entirely and publish only to I2P, Tor, Gemini, IPFS, Reticulum, or any combination of supported targets. +## Deployment profiles + +Profiles are named command defaults stored in `gateway.toml`. They can select enabled +protocols, set a default platform provider, and turn on safe plan artifact writes. + +```toml +[profiles.mobile-i2p] +description = "User-space I2P-only deployment for Android/Termux-style hosts." +protocols = ["i2p"] +platform = "android-termux" + +[profiles.vps-full] +description = "Full VPS deployment." +protocols = ["clearnet", "tor", "i2p", "gemini"] +platform = "linux-systemd" +write_artifacts = true +``` + +Use profiles with operational commands: + +```sh +python3 -m ampg --config gateway.toml build --profile mobile-i2p +python3 -m ampg --config gateway.toml apply --dry-run --profile mobile-i2p +``` + +Explicit `--protocol` and `--platform` flags override profile defaults for that command. + For an existing HTML site that should publish only to I2P: ```toml diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 897f951..782dc71 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 --profile mobile-i2p 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 @@ -62,6 +63,10 @@ services. `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. +`--profile` loads named command defaults from `gateway.toml`. Profiles can set selected +protocols, platform provider, and safe artifact-writing defaults. Explicit command-line +flags override profile defaults. + Platform providers describe how AMPG may supervise managed daemons: - `linux-systemd`: system service management after approval. diff --git a/docs/generated/config-schema.md b/docs/generated/config-schema.md index c7d142a..857859d 100644 --- a/docs/generated/config-schema.md +++ b/docs/generated/config-schema.md @@ -29,3 +29,7 @@ This file is generated from code. Do not edit it by hand. | site.protocols. | `max_asset_bytes` | integer | no | 1048576 | Maximum asset size copied by privacy-html render targets. | | site.protocols. | `script_policy` | enum | no | strip | Script handling policy for privacy-html render targets. | | site.protocols. | `max_tier` | enum | no | transport default | Maximum interaction tier this protocol target may expose. | +| profiles. | `description` | string | no | - | Human-readable deployment profile description. | +| profiles. | `protocols` | array | no | all enabled protocols | Enabled protocols selected by this deployment profile. | +| profiles. | `platform` | enum | no | detected platform | Platform provider used by status, doctor, and apply when not overridden. | +| profiles. | `write_artifacts` | boolean | no | false | Whether plan/apply dry-run writes review artifacts by default. | diff --git a/examples/wownero.gateway.toml b/examples/wownero.gateway.toml index e02dd6d..0a77b6c 100644 --- a/examples/wownero.gateway.toml +++ b/examples/wownero.gateway.toml @@ -4,6 +4,21 @@ cache_dir = "./.ampg/cache" run_dir = "./.ampg/run" user = "ampg" +[profiles.mobile-i2p] +description = "User-space I2P-only deployment for Android/Termux-style hosts." +protocols = ["i2p"] +platform = "android-termux" + +[profiles.tor-i2p] +description = "Privacy-network deployment without clearnet ingress." +protocols = ["tor", "i2p"] + +[profiles.vps-full] +description = "Full VPS deployment for clearnet plus generated alternate transports." +protocols = ["clearnet", "tor", "i2p", "gemini"] +platform = "linux-systemd" +write_artifacts = true + [[site]] id = "wownero" domain = "wownero.org" diff --git a/tests/test_protocol_selection.py b/tests/test_protocol_selection.py index 60f1a34..3c3ac7b 100644 --- a/tests/test_protocol_selection.py +++ b/tests/test_protocol_selection.py @@ -6,7 +6,12 @@ from pathlib import Path from ampg.cli import main from ampg.config import load_config -from ampg.selection import parse_protocol_filters, select_protocols +from ampg.selection import ( + parse_protocol_filters, + protocols_for_selection, + select_profile, + select_protocols, +) def _write_config(root: Path) -> Path: @@ -20,6 +25,18 @@ def _write_config(root: Path) -> Path: config_path = root / "gateway.toml" config_path.write_text( """ +[profiles.mobile-i2p] +protocols = ["i2p"] +platform = "android-termux" + +[profiles.tor-i2p] +protocols = ["tor", "i2p"] + +[profiles.artifact-profile] +protocols = ["i2p"] +platform = "android-termux" +write_artifacts = true + [[site]] id = "example" domain = "example.test" @@ -69,6 +86,37 @@ class ProtocolSelectionTest(unittest.TestCase): self.assertEqual(["i2p"], list(selected.sites[0].protocols)) + def test_config_parses_profiles(self): + with tempfile.TemporaryDirectory() as tmp: + config = load_config(_write_config(Path(tmp))) + + profile = select_profile(config, "mobile-i2p") + self.assertIsNotNone(profile) + self.assertEqual(("i2p",), profile.protocols) + self.assertEqual("android-termux", profile.platform) + self.assertFalse(profile.write_artifacts) + + def test_profile_supplies_protocols_when_no_cli_protocol_is_given(self): + with tempfile.TemporaryDirectory() as tmp: + config = load_config(_write_config(Path(tmp))) + + self.assertEqual( + ("i2p",), + protocols_for_selection( + config, + raw_protocols=[], + profile_name="mobile-i2p", + ), + ) + self.assertEqual( + ("tor",), + protocols_for_selection( + config, + raw_protocols=["tor"], + profile_name="mobile-i2p", + ), + ) + def test_select_protocols_rejects_unknown_protocol(self): with tempfile.TemporaryDirectory() as tmp: config = load_config(_write_config(Path(tmp))) @@ -122,6 +170,87 @@ class ProtocolSelectionTest(unittest.TestCase): self.assertIn("blocked=0", output_text) self.assertNotIn("protocol=clearnet", output_text) + def test_apply_profile_filters_protocol_and_uses_platform(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", + "--profile", + "mobile-i2p", + ] + ) + + output_text = stdout.getvalue() + + self.assertEqual(0, status) + self.assertIn("protocol=i2p", output_text) + self.assertIn("i2pd is missing; AMPG would prepare managed setup", output_text) + self.assertIn("blocked=0", output_text) + self.assertNotIn("protocol=clearnet", output_text) + + def test_cli_protocol_overrides_profile_protocols(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _write_config(Path(tmp)) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + [ + "--config", + str(config_path), + "status", + "--profile", + "mobile-i2p", + "--protocol", + "tor", + ] + ) + + output_text = stdout.getvalue() + + self.assertEqual(0, status) + self.assertIn("protocol=tor", output_text) + self.assertIn("platform=android-termux", output_text) + self.assertNotIn("protocol=i2p", output_text) + + def test_profile_write_artifacts_defaults_apply_artifacts(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", + "--profile", + "artifact-profile", + ] + ) + + output_text = stdout.getvalue() + + self.assertEqual(0, status) + self.assertIn("AMPG_APPLY_ARTIFACT", output_text) + self.assertIn("blocked=0", output_text) + def test_cli_rejects_unknown_protocol_filter(self): with tempfile.TemporaryDirectory() as tmp: config_path = _write_config(Path(tmp)) @@ -133,6 +262,17 @@ class ProtocolSelectionTest(unittest.TestCase): self.assertEqual(1, status) self.assertIn("selected protocol(s) not declared", stderr.getvalue()) + def test_cli_rejects_unknown_profile(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", "--profile", "nope"]) + + self.assertEqual(1, status) + self.assertIn("unknown profile", stderr.getvalue()) + if __name__ == "__main__": unittest.main()