diff --git a/README.md b/README.md index 285ad24..511342d 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ config first, then captured addresses, then deterministic placeholders. `apply --dry-run` includes an address stage. Placeholder addresses remain review items until the generated transport identity is captured or configured. With `--write-artifacts`, -apply also prints the reviewed config artifacts that would be copied into managed state. +apply also prints reviewed config artifacts that would be copied into managed state and +the supervisor services that would be registered or started. 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 diff --git a/ampg/cli.py b/ampg/cli.py index 723817e..2fd172b 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -26,8 +26,10 @@ from .health import HealthCheck, blocked_health_checks, health_plan from .install_plan import ( InstallStateCopy, InstallStep, + InstallSupervisorAction, blocked_install_steps, install_state_copies, + install_supervisor_actions, install_plan, write_install_artifacts, ) @@ -305,6 +307,8 @@ def _cmd_apply(config, args) -> int: print(f"AMPG_APPLY_ARTIFACT path={path} status=written") for copy in install_state_copies(config, platform_provider=platform_provider): _print_install_state_copy(copy) + for action in install_supervisor_actions(config, platform_provider=platform_provider): + _print_supervisor_action(action) steps = activation_steps(config, platform_provider=platform_provider) for step in steps: @@ -543,6 +547,21 @@ def _print_install_state_copy(copy: InstallStateCopy) -> None: ) +def _print_supervisor_action(action: InstallSupervisorAction) -> None: + print( + "AMPG_APPLY_SUPERVISOR " + f"site={action.site_id} " + f"protocol={action.protocol} " + f"platform={action.platform} " + f"kind={action.kind} " + f"service={action.service} " + f"source=\"{_quote(str(action.source))}\" " + f"status={action.status} " + f"command=\"{_quote(action.command)}\" " + f"message=\"{_quote(action.message)}\"" + ) + + def _print_activation_step(step: ActivationStep) -> None: print( "AMPG_APPLY_STEP " diff --git a/ampg/install_plan.py b/ampg/install_plan.py index b31e461..54e24c6 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -47,6 +47,19 @@ class InstallStateCopy: message: str +@dataclass(frozen=True) +class InstallSupervisorAction: + site_id: str + protocol: str + platform: str + kind: str + service: str + source: Path + status: str + command: str + message: str + + def install_plan( config: GatewayConfig, *, @@ -143,6 +156,31 @@ def install_state_copies( return copies +def install_supervisor_actions( + config: GatewayConfig, + *, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> list[InstallSupervisorAction]: + provider = platform_provider or detect_platform() + statuses = gateway_status( + config, + platform_provider=provider, + daemon_probe=daemon_probe, + ) + status_by_protocol = { + (status.site_id, status.protocol): status for status in statuses + } + actions: list[InstallSupervisorAction] = [] + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + status = status_by_protocol[(site.id, protocol.name)] + actions.extend(_protocol_supervisor_actions(config, site, protocol, provider, status)) + return actions + + def _protocol_install_steps( config: GatewayConfig, site: SiteConfig, @@ -351,6 +389,51 @@ def _protocol_state_copies( return copies +def _protocol_supervisor_actions( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, + provider: PlatformProvider, + status: TransportStatus, +) -> list[InstallSupervisorAction]: + actions: list[InstallSupervisorAction] = [] + for artifact in _protocol_install_artifacts(config, site, protocol, provider, status): + service_suffix = _supervisor_service_suffix(protocol, artifact) + if service_suffix is None: + continue + service = _artifact_service_name(site, protocol, service_suffix) + source_exists = artifact.path.exists() + actions.append( + InstallSupervisorAction( + site_id=site.id, + protocol=protocol.name, + platform=provider.name, + kind=artifact.kind, + service=service, + source=artifact.path, + status="planned" if source_exists else "review", + command=_supervisor_start_command(provider, service), + message=( + "register and start reviewed supervisor after state config is copied" + if source_exists + else "supervisor artifact is missing; rerun with --write-artifacts first" + ), + ) + ) + return actions + + +def _supervisor_service_suffix( + protocol: ProtocolConfig, + artifact: InstallArtifact, +) -> str | None: + if artifact.kind == "daemon-supervisor": + return protocol.daemon + if artifact.kind == "loopback-supervisor": + return "nginx-loopback" + return None + + def _state_copy_target( config: GatewayConfig, site: SiteConfig, @@ -444,6 +527,18 @@ def _supervisor_command( return "-" +def _supervisor_start_command(provider: PlatformProvider, service: str) -> str: + if provider.name == "android-termux": + return f"sv-enable {service}" + if provider.name == "linux-systemd": + return f"sudo systemctl enable --now {service}.service" + if provider.name == "macos-launchd": + return f"launchctl bootstrap gui/$UID ~/Library/LaunchAgents/org.ampg.{service}.plist" + if provider.name == "linux-user": + return f"run {service} under the user supervisor" + return "-" + + def _state_dir( config: GatewayConfig, provider: PlatformProvider, diff --git a/docs/architecture.md b/docs/architecture.md index f2377e5..8518746 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,8 +113,8 @@ render-only environment while making daemon ownership decisions explicit. activation sequence. Each enabled protocol gets output, artifact, daemon, and health steps, plus an address step when AMPG must capture or confirm a generated transport identity. With `--write-artifacts`, it also prints planned copies from reviewed artifacts -into managed state. Blocked steps stop the command before any live apply path can touch -services. +into managed state and planned supervisor start/reload actions. Blocked steps stop the +command before any live apply path can touch services. The dry-run command is intentionally stricter than `doctor`: missing generated output is blocked during activation, because AMPG must not point a transport at an unbuilt output diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 180d05f..9a213d7 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -86,8 +86,8 @@ Missing output blocks the plan, while placeholder transport addresses are review output readiness, config artifacts to review, daemon action, address capture or reuse, and post-apply health checks. It exits nonzero when any step is blocked. `--write-artifacts` writes reviewable config snippets to the configured plan root, then prints the planned -copies from those reviewed artifacts into `gateway.state_dir`. It still does not install -or reload services. +copies from those reviewed artifacts into `gateway.state_dir` and the supervisor services +that would be registered or started. It 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 diff --git a/docs/state-contract.md b/docs/state-contract.md index dc7bf16..bd66a0e 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -39,6 +39,7 @@ Current managed defaults: AMPG must never delete sensitive contract paths without an explicit destructive command. `apply --dry-run --write-artifacts` prints `AMPG_APPLY_STATE_COPY` rows that show which -reviewed artifacts would be copied into managed state. Live apply should perform those +reviewed artifacts would be copied into managed state and `AMPG_APPLY_SUPERVISOR` rows +for the services that would be registered or started. Live apply should perform those reviewed copies, start daemons, capture public addresses, and then run published health checks. diff --git a/tests/test_activation.py b/tests/test_activation.py index d7288fa..031271c 100644 --- a/tests/test_activation.py +++ b/tests/test_activation.py @@ -247,6 +247,9 @@ daemon_policy = "auto" self.assertIn("kind=loopback-config", output_text) self.assertIn(str(root / ".ampg/state/example/i2p/i2pd-tunnels.conf"), output_text) self.assertIn(str(root / ".ampg/state/example/i2p/nginx-loopback.conf"), output_text) + self.assertIn("AMPG_APPLY_SUPERVISOR site=example protocol=i2p", output_text) + self.assertIn("service=ampg-example-i2p-i2pd", output_text) + self.assertIn("service=ampg-example-i2p-nginx-loopback", output_text) if __name__ == "__main__": diff --git a/tests/test_install_plan.py b/tests/test_install_plan.py index b5a8ba1..474fdb7 100644 --- a/tests/test_install_plan.py +++ b/tests/test_install_plan.py @@ -11,6 +11,7 @@ from ampg.install_plan import ( install_artifacts, install_plan, install_state_copies, + install_supervisor_actions, write_install_artifacts, ) from ampg.platforms import platform_by_name @@ -304,6 +305,47 @@ daemon_policy = "auto" ) self.assertIn("cp", by_kind["daemon-config"].command) + def test_supervisor_action_plan_uses_reviewed_supervisor_artifacts(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config = load_config( + _write_config( + root, + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +""", + ) + ) + write_install_artifacts( + config, + platform_provider=platform_by_name("android-termux"), + daemon_probe=_probe(installed=False, running=False), + ) + + actions = install_supervisor_actions( + config, + platform_provider=platform_by_name("android-termux"), + daemon_probe=_probe(installed=False, running=False), + ) + + by_kind = {action.kind: action for action in actions} + self.assertEqual({"daemon-supervisor", "loopback-supervisor"}, set(by_kind)) + self.assertEqual("planned", by_kind["daemon-supervisor"].status) + self.assertEqual("ampg-example-i2p-i2pd", by_kind["daemon-supervisor"].service) + self.assertEqual("sv-enable ampg-example-i2p-i2pd", by_kind["daemon-supervisor"].command) + self.assertEqual( + root / "plan/example/i2p/managed/android-termux/i2pd.run", + by_kind["daemon-supervisor"].source, + ) + self.assertEqual( + "sv-enable ampg-example-i2p-nginx-loopback", + by_kind["loopback-supervisor"].command, + ) + def test_install_plan_cli_can_write_managed_artifacts(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)