From 3063e61676861c721be28e4577775d22d67928c1 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 11:49:36 -0400 Subject: [PATCH] Plan managed state artifact copies --- README.md | 3 +- ampg/activation.py | 19 +++++++- ampg/cli.py | 24 +++++++++- ampg/install_plan.py | 93 ++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 4 +- docs/daemon-management.md | 3 +- docs/state-contract.md | 6 ++- tests/test_activation.py | 39 ++++++++++++++++ tests/test_install_plan.py | 41 +++++++++++++++++ 9 files changed, 223 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 80599f1..285ad24 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ Captured transport addresses are written under `gateway.state_dir` by 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. +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. 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/activation.py b/ampg/activation.py index 8f7c7f7..564dc58 100644 --- a/ampg/activation.py +++ b/ampg/activation.py @@ -6,6 +6,7 @@ import shlex from .addresses import AddressRecord, address_registry_path, effective_address_records from .config import GatewayConfig, ProtocolConfig, SiteConfig +from .install_plan import write_install_artifacts from .plan import plan_artifacts, write_plan_artifacts from .platforms import PlatformProvider from .status import ( @@ -257,5 +258,19 @@ def _transport_activation_status(status: TransportStatus) -> str: return "ready" -def write_activation_artifacts(config: GatewayConfig) -> tuple[Path, ...]: - return tuple(artifact.path for artifact in write_plan_artifacts(config)) +def write_activation_artifacts( + config: GatewayConfig, + *, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> tuple[Path, ...]: + paths = [artifact.path for artifact in write_plan_artifacts(config)] + paths.extend( + artifact.path + for artifact in write_install_artifacts( + config, + platform_provider=platform_provider, + daemon_probe=daemon_probe, + ) + ) + return tuple(paths) diff --git a/ampg/cli.py b/ampg/cli.py index e4003bf..723817e 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -24,8 +24,10 @@ from .config import load_config from .docsgen import generate_docs from .health import HealthCheck, blocked_health_checks, health_plan from .install_plan import ( + InstallStateCopy, InstallStep, blocked_install_steps, + install_state_copies, install_plan, write_install_artifacts, ) @@ -297,11 +299,14 @@ def _cmd_apply(config, args) -> int: ) return 1 + platform_provider = _platform_override(config, args) if _write_artifacts_enabled(config, args): - for path in write_activation_artifacts(config): + for path in write_activation_artifacts(config, platform_provider=platform_provider): 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) - steps = activation_steps(config, platform_provider=_platform_override(config, args)) + steps = activation_steps(config, platform_provider=platform_provider) for step in steps: _print_activation_step(step) @@ -523,6 +528,21 @@ def _print_install_step(step: InstallStep) -> None: ) +def _print_install_state_copy(copy: InstallStateCopy) -> None: + print( + "AMPG_APPLY_STATE_COPY " + f"site={copy.site_id} " + f"protocol={copy.protocol} " + f"platform={copy.platform} " + f"kind={copy.kind} " + f"source=\"{_quote(str(copy.source))}\" " + f"target=\"{_quote(str(copy.target))}\" " + f"status={copy.status} " + f"command=\"{_quote(copy.command)}\" " + f"message=\"{_quote(copy.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 8c57b81..b31e461 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +import shlex from .config import GatewayConfig, ProtocolConfig, SiteConfig from .platforms import PlatformProvider, detect_platform @@ -33,6 +34,19 @@ class InstallArtifact: content: str +@dataclass(frozen=True) +class InstallStateCopy: + site_id: str + protocol: str + platform: str + kind: str + source: Path + target: Path + status: str + command: str + message: str + + def install_plan( config: GatewayConfig, *, @@ -104,6 +118,31 @@ def write_install_artifacts( return artifacts +def install_state_copies( + config: GatewayConfig, + *, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> list[InstallStateCopy]: + 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 + } + copies: list[InstallStateCopy] = [] + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + status = status_by_protocol[(site.id, protocol.name)] + copies.extend(_protocol_state_copies(config, site, protocol, provider, status)) + return copies + + def _protocol_install_steps( config: GatewayConfig, site: SiteConfig, @@ -279,6 +318,52 @@ def _protocol_install_artifacts( return artifacts +def _protocol_state_copies( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, + provider: PlatformProvider, + status: TransportStatus, +) -> list[InstallStateCopy]: + copies: list[InstallStateCopy] = [] + for artifact in _protocol_install_artifacts(config, site, protocol, provider, status): + target = _state_copy_target(config, site, protocol, artifact) + if target is None: + continue + source_exists = artifact.path.exists() + copies.append( + InstallStateCopy( + site_id=site.id, + protocol=protocol.name, + platform=provider.name, + kind=artifact.kind, + source=artifact.path, + target=target, + status="planned" if source_exists else "review", + command=_copy_command(provider, artifact.path, target), + message=( + "copy reviewed artifact into managed state before daemon start" + if source_exists + else "review artifact is missing; rerun with --write-artifacts first" + ), + ) + ) + return copies + + +def _state_copy_target( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, + artifact: InstallArtifact, +) -> Path | None: + if artifact.kind == "daemon-config": + return daemon_config_path(config, site, protocol) + if artifact.kind == "loopback-config": + return protocol_state_dir(config, site, protocol) / artifact.path.name + return None + + def _package_step( site: SiteConfig, protocol: ProtocolConfig, @@ -334,6 +419,14 @@ def _mkdir_command(provider: PlatformProvider, path: Path) -> str: return f"{prefix}mkdir -p {path}" +def _copy_command(provider: PlatformProvider, source: Path, target: Path) -> str: + prefix = "sudo " if provider.can_write_system_config else "" + parent = shlex.quote(str(target.parent)) + source_text = shlex.quote(str(source)) + target_text = shlex.quote(str(target)) + return f"{prefix}mkdir -p {parent} && {prefix}cp {source_text} {target_text}" + + def _supervisor_command( provider: PlatformProvider, site: SiteConfig, diff --git a/docs/architecture.md b/docs/architecture.md index 8eaa33c..f2377e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,9 @@ 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, 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. +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. 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 abe6c2b..180d05f 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -85,7 +85,8 @@ Missing output blocks the plan, while placeholder transport addresses are review `apply --dry-run` prints the activation sequence for each enabled protocol: generated 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 +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. `--protocol` scopes operational commands to selected enabled protocols. A clearnet diff --git a/docs/state-contract.md b/docs/state-contract.md index 6fab221..dc7bf16 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -38,5 +38,7 @@ Current managed defaults: ## Safety AMPG must never delete sensitive contract paths without an explicit destructive command. -Live apply should copy reviewed config artifacts into their contract `daemon-config` -paths, start daemons, capture public addresses, and then run published health checks. +`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 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 7eb8066..d7288fa 100644 --- a/tests/test_activation.py +++ b/tests/test_activation.py @@ -209,6 +209,45 @@ port = 1965 ) self.assertTrue(artifact_path.exists()) + def test_apply_dry_run_write_artifacts_prints_state_copy_plan(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config_path = _write_config( + root, + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +""", + ) + 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", + "--write-artifacts", + "--platform", + "android-termux", + ] + ) + + output_text = stdout.getvalue() + self.assertEqual(0, status) + self.assertIn("AMPG_APPLY_STATE_COPY site=example protocol=i2p", output_text) + self.assertIn("kind=daemon-config", output_text) + 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) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_plan.py b/tests/test_install_plan.py index f5d95e3..b5a8ba1 100644 --- a/tests/test_install_plan.py +++ b/tests/test_install_plan.py @@ -10,6 +10,7 @@ from ampg.install_plan import ( blocked_install_steps, install_artifacts, install_plan, + install_state_copies, write_install_artifacts, ) from ampg.platforms import platform_by_name @@ -263,6 +264,46 @@ daemon_policy = "auto" ).exists() ) + def test_state_copy_plan_maps_review_artifacts_to_managed_state(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), + ) + + copies = install_state_copies( + config, + platform_provider=platform_by_name("android-termux"), + daemon_probe=_probe(installed=False, running=False), + ) + + by_kind = {copy.kind: copy for copy in copies} + self.assertEqual({"daemon-config", "loopback-config"}, set(by_kind)) + self.assertEqual("planned", by_kind["daemon-config"].status) + self.assertEqual( + root / ".ampg/state/example/i2p/i2pd-tunnels.conf", + by_kind["daemon-config"].target, + ) + self.assertEqual( + root / ".ampg/state/example/i2p/nginx-loopback.conf", + by_kind["loopback-config"].target, + ) + self.assertIn("cp", by_kind["daemon-config"].command) + def test_install_plan_cli_can_write_managed_artifacts(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)