From 12983ca134018fb0eecadf13c0639d16cd0329ab Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 10:30:42 -0400 Subject: [PATCH] Add address capture to activation plan --- README.md | 6 ++- ampg/activation.py | 88 ++++++++++++++++++++++++++++++++++---- ampg/cli.py | 1 + ampg/install_plan.py | 48 +++++++++++++-------- docs/architecture.md | 5 ++- docs/configuration.md | 4 +- docs/daemon-management.md | 11 ++--- tests/test_activation.py | 38 +++++++++++++++- tests/test_install_plan.py | 8 +++- 9 files changed, 169 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e1ca8bf..dee9d62 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,16 @@ passed. They are reviewable snippets, not installed daemon config. Managed install artifacts are also written under `dist/ampg-plan/` when `install-plan --write-artifacts` is passed. They include reviewable daemon configs and -platform supervisor files, but are not installed or started. +platform supervisor files, but are not installed or started. Managed daemon configs point +runtime state at `gateway.state_dir`. Captured transport addresses are written under `gateway.state_dir` by `addresses capture` or `addresses set`. Fixture manifests and health plans use explicit 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. + 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. diff --git a/ampg/activation.py b/ampg/activation.py index ee3f6e5..8f7c7f7 100644 --- a/ampg/activation.py +++ b/ampg/activation.py @@ -2,7 +2,9 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +import shlex +from .addresses import AddressRecord, address_registry_path, effective_address_records from .config import GatewayConfig, ProtocolConfig, SiteConfig from .plan import plan_artifacts, write_plan_artifacts from .platforms import PlatformProvider @@ -23,6 +25,7 @@ class ActivationStep: target: str status: str message: str + command: str = "-" def activation_steps( @@ -39,6 +42,10 @@ def activation_steps( status_by_protocol = { (status.site_id, status.protocol): status for status in statuses } + address_by_protocol = { + (record.site_id, record.protocol): record + for record in effective_address_records(config) + } steps: list[ActivationStep] = [] for issue in doctor_gateway( @@ -69,9 +76,11 @@ def activation_steps( transport_status = status_by_protocol[(site.id, protocol.name)] steps.extend( _protocol_steps( + config, site, protocol, transport_status=transport_status, + address_record=address_by_protocol[(site.id, protocol.name)], output_ready=output_ready, ) ) @@ -84,10 +93,12 @@ def blocked_steps(steps: list[ActivationStep]) -> list[ActivationStep]: def _protocol_steps( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, *, transport_status: TransportStatus, + address_record: AddressRecord, output_ready: bool, ) -> list[ActivationStep]: output_root = site.outputs.root / protocol.name @@ -145,6 +156,7 @@ def _protocol_steps( message=transport_status.message, ) ) + steps.append(_address_step(config, site, protocol, address_record)) steps.append( ActivationStep( site_id=site.id, @@ -152,21 +164,81 @@ def _protocol_steps( stage="health", action="check-transport", target=protocol.name, - status=( - "planned" - if output_ready and transport_status.status != "error" - else "blocked" + status=_health_activation_status( + output_ready=output_ready, + transport_status=transport_status, + address_record=address_record, ), - message=( - "verify generated site through this transport after apply" - if output_ready and transport_status.status != "error" - else "health check is blocked until output and daemon steps are ready" + message=_health_activation_message( + output_ready=output_ready, + transport_status=transport_status, + address_record=address_record, ), ) ) return steps +def _address_step( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, + address_record: AddressRecord, +) -> ActivationStep: + if address_record.address_status == "placeholder": + return ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="address", + action="capture-address", + target=str(address_registry_path(config)), + status="review", + message="generated transport address is not known yet", + command=_address_capture_command(config, protocol), + ) + return ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="address", + action="use-address", + target=address_record.url, + status="ready", + message=f"using {address_record.address_status} transport address", + command="-", + ) + + +def _address_capture_command(config: GatewayConfig, protocol: ProtocolConfig) -> str: + config_path = shlex.quote(str(config.config_path)) + return f"python3 -m ampg --config {config_path} addresses capture --protocol {protocol.name}" + + +def _health_activation_status( + *, + output_ready: bool, + transport_status: TransportStatus, + address_record: AddressRecord, +) -> str: + if not output_ready or transport_status.status == "error": + return "blocked" + if address_record.address_status == "placeholder": + return "review" + return "planned" + + +def _health_activation_message( + *, + output_ready: bool, + transport_status: TransportStatus, + address_record: AddressRecord, +) -> str: + if not output_ready or transport_status.status == "error": + return "health check is blocked until output and daemon steps are ready" + if address_record.address_status == "placeholder": + return "capture or configure the generated transport address before published checks" + return "verify generated site through this transport after apply" + + def _output_ready(site: SiteConfig, protocol: ProtocolConfig) -> bool: return ((site.outputs.root / protocol.name) / ".ampg-output").exists() diff --git a/ampg/cli.py b/ampg/cli.py index a8703b9..3c5f8f4 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -524,6 +524,7 @@ def _print_activation_step(step: ActivationStep) -> None: f"action={step.action} " f"target={step.target} " f"status={step.status} " + f"command=\"{_quote(step.command)}\" " f"message=\"{_quote(step.message)}\"" ) diff --git a/ampg/install_plan.py b/ampg/install_plan.py index be5dc4d..4ca3140 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -53,7 +53,7 @@ def install_plan( if not protocol.enabled: continue status = status_by_protocol[(site.id, protocol.name)] - steps.extend(_protocol_install_steps(site, protocol, provider, status)) + steps.extend(_protocol_install_steps(config, site, protocol, provider, status)) return steps @@ -82,7 +82,7 @@ def install_artifacts( if not protocol.enabled: continue status = status_by_protocol[(site.id, protocol.name)] - artifacts.extend(_protocol_install_artifacts(site, protocol, provider, status)) + artifacts.extend(_protocol_install_artifacts(config, site, protocol, provider, status)) return artifacts @@ -104,6 +104,7 @@ def write_install_artifacts( def _protocol_install_steps( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, @@ -170,9 +171,9 @@ def _protocol_install_steps( provider, stage="state", action="create-state", - target=str(_state_dir(provider, site, protocol)), + target=str(_state_dir(config, provider, site, protocol)), status="planned", - command=_mkdir_command(provider, _state_dir(provider, site, protocol)), + command=_mkdir_command(provider, _state_dir(config, provider, site, protocol)), message="create AMPG-owned state directory for daemon config, keys, and logs", ), _step( @@ -181,7 +182,7 @@ def _protocol_install_steps( provider, stage="config", action="write-managed-config", - target=str(_state_dir(provider, site, protocol) / "config"), + target=str(_state_dir(config, provider, site, protocol) / "config"), status="planned", command="-", message=f"write managed {adapter.backend} config after plan review", @@ -212,6 +213,7 @@ def _protocol_install_steps( def _protocol_install_artifacts( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, @@ -227,7 +229,7 @@ def _protocol_install_artifacts( provider, kind="daemon-config", filename=_daemon_config_filename(protocol), - content=_daemon_config_content(site, protocol, provider), + content=_daemon_config_content(config, site, protocol, provider), ), _artifact( site, @@ -236,11 +238,12 @@ def _protocol_install_artifacts( kind="daemon-supervisor", filename=_supervisor_filename(provider, protocol.daemon), content=_supervisor_content( + config, site, protocol, provider, service_suffix=protocol.daemon, - command=_daemon_start_command(site, protocol, provider), + command=_daemon_start_command(config, site, protocol, provider), ), ), ] @@ -253,7 +256,7 @@ def _protocol_install_artifacts( provider, kind="loopback-config", filename="nginx-loopback.conf", - content=_managed_nginx_loopback_content(site, protocol, provider), + content=_managed_nginx_loopback_content(config, site, protocol, provider), ), _artifact( site, @@ -262,11 +265,12 @@ def _protocol_install_artifacts( kind="loopback-supervisor", filename=_supervisor_filename(provider, "nginx-loopback"), content=_supervisor_content( + config, site, protocol, provider, service_suffix="nginx-loopback", - command=_loopback_start_command(site, protocol, provider), + command=_loopback_start_command(config, site, protocol, provider), ), ), ) @@ -347,11 +351,13 @@ def _supervisor_command( def _state_dir( + config: GatewayConfig, provider: PlatformProvider, site: SiteConfig, protocol: ProtocolConfig, ) -> Path: - return provider.state_root / site.id / protocol.name + root = config.paths.state_dir if config.paths else provider.state_root + return root / site.id / protocol.name def _artifact_dir( @@ -387,11 +393,12 @@ def _daemon_config_filename(protocol: ProtocolConfig) -> str: def _daemon_config_content( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, ) -> str: - state_dir = _state_dir(provider, site, protocol) + state_dir = _state_dir(config, provider, site, protocol) if protocol.name == "tor": port = int(protocol.options.get("local_port", 18080)) hidden_service_dir = state_dir / "hidden-service" @@ -423,7 +430,7 @@ certificate = {state_dir / f"{site.id}.crt"} private_key = {state_dir / f"{site.id}.key"} """ if protocol.name == "clearnet": - return _managed_nginx_server_content(site, protocol, provider) + return _managed_nginx_server_content(config, site, protocol, provider) return f"""# Generated by AMPG. Review before installing. daemon = {protocol.daemon} protocol = {protocol.name} @@ -433,11 +440,12 @@ state_dir = {state_dir} def _managed_nginx_server_content( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, ) -> str: - state_dir = _state_dir(provider, site, protocol) + state_dir = _state_dir(config, provider, site, protocol) return f"""# Generated by AMPG. Review before installing. worker_processes 1; error_log {state_dir / "nginx-error.log"} notice; @@ -465,11 +473,12 @@ http {{ def _managed_nginx_loopback_content( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, ) -> str: - state_dir = _state_dir(provider, site, protocol) + state_dir = _state_dir(config, provider, site, protocol) port = int(protocol.options.get("local_port", 18080 if protocol.name == "tor" else 18081)) return f"""# Generated by AMPG. Review before installing. worker_processes 1; @@ -505,6 +514,7 @@ def _supervisor_filename(provider: PlatformProvider, service_suffix: str) -> str def _supervisor_content( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, @@ -522,7 +532,7 @@ Wants=network-online.target [Service] Type=simple -WorkingDirectory={_state_dir(provider, site, protocol)} +WorkingDirectory={_state_dir(config, provider, site, protocol)} ExecStart=/bin/sh -lc '{_shell_single_quote(command)}' Restart=on-failure RestartSec=5 @@ -555,17 +565,18 @@ WantedBy=multi-user.target return f"""#!{shell} # Generated by AMPG. Review before installing. set -eu -mkdir -p {_state_dir(provider, site, protocol)} +mkdir -p {_state_dir(config, provider, site, protocol)} {command} """ def _daemon_start_command( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, ) -> str: - state_dir = _state_dir(provider, site, protocol) + state_dir = _state_dir(config, provider, site, protocol) if protocol.name == "tor": return f"exec tor -f {state_dir / 'torrc'}" if protocol.name == "i2p": @@ -585,11 +596,12 @@ def _daemon_start_command( def _loopback_start_command( + config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig, provider: PlatformProvider, ) -> str: - state_dir = _state_dir(provider, site, protocol) + state_dir = _state_dir(config, provider, site, protocol) return f"exec nginx -c {state_dir / 'nginx-loopback.conf'} -g 'daemon off;'" diff --git a/docs/architecture.md b/docs/architecture.md index fa71bc4..8eaa33c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,8 @@ render-only environment while making daemon ownership decisions explicit. `ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered activation sequence. Each enabled protocol gets output, artifact, daemon, and health -steps. Blocked steps stop the command before any live apply path can touch services. +steps, plus an address step when AMPG must capture or confirm a generated transport +identity. 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 @@ -126,7 +127,7 @@ future live apply support. With `--write-artifacts`, install-plan writes managed config and supervisor files under the configured `plan_root`; those files are review artifacts, not installed service -state. +state. Generated managed-daemon configs point runtime state at `gateway.state_dir`. Tor and I2P HTTP publishing include the transport daemon and the loopback web-serving layer in the install plan, because the hidden service or tunnel must have a local HTTP diff --git a/docs/configuration.md b/docs/configuration.md index 44728a0..0379be2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,8 +112,8 @@ workflow. `[gateway]` paths are resolved relative to `gateway.toml` when they are not absolute. `state_dir` -: AMPG-owned durable state. Managed daemon identities, generated transport state, and - the captured address registry live here. +: AMPG-owned durable state. Managed daemon identities, generated transport state, daemon + logs, daemon-written addresses, and the captured address registry live here. `cache_dir` : Rebuildable intermediate data. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 8bd479a..5a9c5cf 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -66,7 +66,8 @@ blocked. `install-plan --write-artifacts` writes managed daemon configs and platform supervisor files under the configured `plan_root`, grouped by site, protocol, and platform. For Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopback nginx -artifacts. +artifacts. The generated daemon configs store runtime state, identity keys, logs, and +daemon-written addresses under `gateway.state_dir`. `addresses list` prints the effective public address for each enabled protocol: configured, captured, derived, or placeholder. `addresses capture` reads daemon-written @@ -78,10 +79,10 @@ target real transport URLs; preview checks target local loopback preview endpoin Missing output blocks the plan, while placeholder transport addresses are review items. `apply --dry-run` prints the activation sequence for each enabled protocol: generated -output readiness, config artifacts to review, daemon action, and post-apply health -checks. It exits nonzero when any step is blocked. `--write-artifacts` writes reviewable -config snippets to the configured plan root but still does not install or reload -services. +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 but 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/tests/test_activation.py b/tests/test_activation.py index 2eadd05..7eb8066 100644 --- a/tests/test_activation.py +++ b/tests/test_activation.py @@ -50,7 +50,7 @@ plan_root = "./plan" class ActivationTest(unittest.TestCase): - def test_activation_plan_is_ready_when_output_and_daemon_policy_are_ready(self): + def test_activation_plan_reviews_placeholder_address_before_health(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) config_path = _write_config( @@ -79,9 +79,42 @@ local_port = 19081 by_stage = {step.stage: step for step in steps if step.stage != "artifact"} self.assertEqual("ready", by_stage["output"].status) self.assertEqual("manage-owned", by_stage["daemon"].action) - self.assertEqual("planned", by_stage["health"].status) + self.assertEqual("capture-address", by_stage["address"].action) + self.assertEqual("review", by_stage["address"].status) + self.assertIn("addresses capture --protocol i2p", by_stage["address"].command) + self.assertEqual("review", by_stage["health"].status) self.assertTrue(any(step.stage == "artifact" for step in steps)) + def test_configured_address_allows_planned_activation_health(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config_path = _write_config( + root, + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +i2p_url = "ready.b32.i2p" +""", + ) + output = root / "out/i2p" + output.mkdir(parents=True) + (output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8") + config = load_config(config_path) + + steps = activation_steps( + config, + platform_provider=platform_by_name("android-termux"), + daemon_probe=_probe(installed=False, running=False), + ) + + by_stage = {step.stage: step for step in steps if step.stage != "artifact"} + self.assertEqual("use-address", by_stage["address"].action) + self.assertEqual("ready", by_stage["address"].status) + self.assertEqual("planned", by_stage["health"].status) + def test_missing_output_blocks_activation(self): with tempfile.TemporaryDirectory() as tmp: config_path = _write_config( @@ -162,6 +195,7 @@ port = 1965 output_text = stdout.getvalue() self.assertIn("AMPG_APPLY_ARTIFACT", output_text) self.assertIn("AMPG_APPLY_STEP", output_text) + self.assertIn('command="-"', output_text) self.assertIn("blocked=0", output_text) artifact_line = next( line for line in output_text.splitlines() if line.startswith("AMPG_APPLY_ARTIFACT") diff --git a/tests/test_install_plan.py b/tests/test_install_plan.py index 4c47b2d..f5d95e3 100644 --- a/tests/test_install_plan.py +++ b/tests/test_install_plan.py @@ -189,7 +189,7 @@ daemon_policy = "auto" def test_managed_i2p_artifacts_include_daemon_loopback_and_supervisors(self): with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) + root = Path(tmp).resolve() config = load_config( _write_config( root, @@ -223,8 +223,12 @@ tunnel_name = "example-web" set(by_name), ) self.assertIn("[example-web]", by_name["i2pd-tunnels.conf"].content) - self.assertIn("keys =", by_name["i2pd-tunnels.conf"].content) + self.assertIn( + f"keys = {root / '.ampg/state/example/i2p/example-web.dat'}", + by_name["i2pd-tunnels.conf"].content, + ) self.assertIn("listen 127.0.0.1:19081", by_name["nginx-loopback.conf"].content) + self.assertIn(f"mkdir -p {root / '.ampg/state/example/i2p'}", by_name["i2pd.run"].content) self.assertIn("exec i2pd", by_name["i2pd.run"].content) self.assertIn("exec nginx", by_name["nginx-loopback.run"].content)