Plan managed supervisor actions

This commit is contained in:
jwinterm
2026-07-06 12:17:05 -04:00
parent 3063e61676
commit a921f158c3
8 changed files with 167 additions and 6 deletions
+2 -1
View File
@@ -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
+19
View File
@@ -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 "
+95
View File
@@ -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,
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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.
+3
View File
@@ -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__":
+42
View File
@@ -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)