From 83847b205bcd38babfcfd8708ea016c0e22fa755 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 04:54:03 -0400 Subject: [PATCH] Add dry-run apply activation plan --- README.md | 10 +- ampg/activation.py | 189 ++++++++++++++++++++++++++++++++++++++ ampg/cli.py | 70 ++++++++++++++ docs/architecture.md | 10 ++ docs/daemon-management.md | 8 ++ tests/test_activation.py | 180 ++++++++++++++++++++++++++++++++++++ 6 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 ampg/activation.py create mode 100644 tests/test_activation.py diff --git a/README.md b/README.md index c600fcc..7f05abc 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,12 @@ ampg init site wownero \ ampg enable wownero tor gemini i2p reticulum ampg plan ampg build -ampg apply +ampg apply --dry-run ``` `plan` prints rendered output paths, daemon decisions, port bindings, config diffs, and -operator actions. `apply` performs only the approved changes. +operator actions. The current `apply --dry-run` prints the activation sequence without +changing services. Live apply support will perform only approved changes. ## Interaction tiers @@ -103,6 +104,8 @@ python3 -m ampg --config examples/wownero.gateway.toml plan 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 --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml manifest python3 -m ampg --config examples/wownero.gateway.toml preview endpoints @@ -132,4 +135,5 @@ passed. They are reviewable snippets, not installed daemon config. - [ ] Run `ampg status` or `ampg doctor`; review daemon ownership decisions. - [ ] Run `ampg build`; inspect generated variants. - [ ] Run AMPB fixture checks against generated manifests. -- [ ] Run `ampg apply`; publish through adopted or managed daemons. +- [ ] Run `ampg apply --dry-run`; review the activation sequence. +- [ ] Run live apply after the activation sequence is clean. diff --git a/ampg/activation.py b/ampg/activation.py new file mode 100644 index 0000000..ee3f6e5 --- /dev/null +++ b/ampg/activation.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .config import GatewayConfig, ProtocolConfig, SiteConfig +from .plan import plan_artifacts, write_plan_artifacts +from .platforms import PlatformProvider +from .status import ( + DaemonProbeFunc, + TransportStatus, + doctor_gateway, + gateway_status, +) + + +@dataclass(frozen=True) +class ActivationStep: + site_id: str + protocol: str + stage: str + action: str + target: str + status: str + message: str + + +def activation_steps( + config: GatewayConfig, + *, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> list[ActivationStep]: + statuses = gateway_status( + config, + platform_provider=platform_provider, + daemon_probe=daemon_probe, + ) + status_by_protocol = { + (status.site_id, status.protocol): status for status in statuses + } + steps: list[ActivationStep] = [] + + for issue in doctor_gateway( + config, + platform_provider=platform_provider, + daemon_probe=daemon_probe, + statuses=statuses, + ): + if issue.code in {"daemon-status", "output-missing"}: + continue + steps.append( + ActivationStep( + site_id=issue.site_id, + protocol=issue.protocol, + stage="preflight", + action="fix-issue", + target="-", + status=_issue_activation_status(issue.severity), + message=f"{issue.code}: {issue.message}", + ) + ) + + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + output_ready = _output_ready(site, protocol) + transport_status = status_by_protocol[(site.id, protocol.name)] + steps.extend( + _protocol_steps( + site, + protocol, + transport_status=transport_status, + output_ready=output_ready, + ) + ) + + return steps + + +def blocked_steps(steps: list[ActivationStep]) -> list[ActivationStep]: + return [step for step in steps if step.status == "blocked"] + + +def _protocol_steps( + site: SiteConfig, + protocol: ProtocolConfig, + *, + transport_status: TransportStatus, + output_ready: bool, +) -> list[ActivationStep]: + output_root = site.outputs.root / protocol.name + steps = [ + ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="output", + action="verify-output", + target=str(output_root), + status="ready" if output_ready else "blocked", + message=( + "generated output marker exists" + if output_ready + else "generated output is missing; run build before apply" + ), + ) + ] + + artifacts = plan_artifacts(site, protocol) + if artifacts: + for artifact in artifacts: + steps.append( + ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="artifact", + action="review-config", + target=str(artifact.path), + status="review", + message="review generated config before installing or reloading services", + ) + ) + else: + steps.append( + ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="artifact", + action="review-config", + target="-", + status="planned", + message="no generated config artifact is available for this adapter yet", + ) + ) + + steps.append( + ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="daemon", + action=transport_status.action, + target=protocol.daemon, + status=_transport_activation_status(transport_status), + message=transport_status.message, + ) + ) + steps.append( + ActivationStep( + site_id=site.id, + protocol=protocol.name, + stage="health", + action="check-transport", + target=protocol.name, + status=( + "planned" + if output_ready and transport_status.status != "error" + else "blocked" + ), + 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" + ), + ) + ) + return steps + + +def _output_ready(site: SiteConfig, protocol: ProtocolConfig) -> bool: + return ((site.outputs.root / protocol.name) / ".ampg-output").exists() + + +def _issue_activation_status(severity: str) -> str: + if severity == "error": + return "blocked" + return "review" + + +def _transport_activation_status(status: TransportStatus) -> str: + if status.status == "error": + return "blocked" + if status.action == "render-only" or status.status == "warn": + return "review" + return "ready" + + +def write_activation_artifacts(config: GatewayConfig) -> tuple[Path, ...]: + return tuple(artifact.path for artifact in write_plan_artifacts(config)) diff --git a/ampg/cli.py b/ampg/cli.py index f217c59..265ff1d 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -4,6 +4,12 @@ import argparse from pathlib import Path import sys +from .activation import ( + ActivationStep, + activation_steps, + blocked_steps, + write_activation_artifacts, +) from .audit import audit_gateway from .build import build_gateway from .config import load_config @@ -54,6 +60,25 @@ def main(argv: list[str] | None = None) -> int: choices=PLATFORM_NAMES, help="Override platform detection for dry-run checks.", ) + apply_parser = subcommands.add_parser( + "apply", + help="Show or run the transport activation sequence.", + ) + apply_parser.add_argument( + "--dry-run", + action="store_true", + help="Print the activation sequence without changing services.", + ) + apply_parser.add_argument( + "--write-artifacts", + action="store_true", + help="Write generated config snippets to the configured plan root.", + ) + apply_parser.add_argument( + "--platform", + choices=PLATFORM_NAMES, + help="Override platform detection for dry-run checks.", + ) subcommands.add_parser("build", help="Build enabled protocol outputs.") subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") preview_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.") @@ -132,6 +157,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_status(config, args) if args.command == "doctor": return _cmd_doctor(config, args) + if args.command == "apply": + return _cmd_apply(config, args) if args.command == "build": return _cmd_build(config) if args.command == "manifest": @@ -148,6 +175,36 @@ def main(argv: list[str] | None = None) -> int: return 1 +def _cmd_apply(config, args) -> int: + if not args.dry_run: + print( + 'AMPG_APPLY status=error message="live apply is not implemented; rerun with --dry-run"', + file=sys.stderr, + ) + return 1 + + if args.write_artifacts: + 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)) + for step in steps: + _print_activation_step(step) + + blocked = blocked_steps(steps) + print( + "AMPG_APPLY_SUMMARY " + "mode=dry-run " + f"sites={len(config.sites)} " + f"steps={len(steps)} " + f"ready={sum(1 for step in steps if step.status == 'ready')} " + f"review={sum(1 for step in steps if step.status == 'review')} " + f"planned={sum(1 for step in steps if step.status == 'planned')} " + f"blocked={len(blocked)}" + ) + return 1 if blocked else 0 + + def _cmd_status(config, args) -> int: statuses = gateway_status(config, platform_provider=_platform_override(args)) for status in statuses: @@ -235,6 +292,19 @@ def _cmd_build(config) -> int: return 0 +def _print_activation_step(step: ActivationStep) -> None: + print( + "AMPG_APPLY_STEP " + f"site={step.site_id} " + f"protocol={step.protocol} " + f"stage={step.stage} " + f"action={step.action} " + f"target={step.target} " + f"status={step.status} " + f"message=\"{_quote(step.message)}\"" + ) + + def _print_transport_status(status: TransportStatus) -> None: executable_path = status.executable_path or "-" print( diff --git a/docs/architecture.md b/docs/architecture.md index 5b45033..70417af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,6 +107,16 @@ python3 -m ampg --config gateway.toml doctor --platform android-termux This keeps the same gateway config portable across a VPS, laptop, old phone, or manual render-only environment while making daemon ownership decisions explicit. +## Activation plan + +`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. + +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 +root. + ## Interaction boundary v1 is static-first, but the architecture reserves a path for interactive applications. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 46e8491..b121402 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -39,6 +39,8 @@ config artifacts. ```sh 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 --write-artifacts python3 -m ampg --config gateway.toml doctor --platform android-termux ``` @@ -49,6 +51,12 @@ adapter, installed/running daemon probe, action, and policy result. daemon policy feasibility. It exits nonzero only for errors; missing build output is a warning so operators can run it before the first build. +`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. + Platform providers describe how AMPG may supervise managed daemons: - `linux-systemd`: system service management after approval. diff --git a/tests/test_activation.py b/tests/test_activation.py new file mode 100644 index 0000000..2eadd05 --- /dev/null +++ b/tests/test_activation.py @@ -0,0 +1,180 @@ +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ampg.activation import activation_steps, blocked_steps +from ampg.cli import main +from ampg.config import load_config +from ampg.platforms import platform_by_name +from ampg.status import DaemonProbe + + +def _probe(*, installed: bool = False, running: bool = False): + def probe(adapter): + executable_path = f"/usr/bin/{adapter.executable}" if installed else None + return DaemonProbe( + installed=installed, + running=running, + executable_path=executable_path, + ) + + return probe + + +def _write_config(root: Path, protocol_block: str) -> Path: + source = root / "site" + source.mkdir() + (source / "index.html").write_text("

Hello

", encoding="utf-8") + config_path = root / "gateway.toml" + config_path.write_text( + f""" +[[site]] +id = "example" +domain = "example.test" + +[site.source] +kind = "static-html" +path = "./site" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +{protocol_block} +""", + encoding="utf-8", + ) + return config_path + + +class ActivationTest(unittest.TestCase): + def test_activation_plan_is_ready_when_output_and_daemon_policy_are_ready(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" +local_port = 19081 +""", + ) + 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), + ) + + self.assertEqual([], blocked_steps(steps)) + 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.assertTrue(any(step.stage == "artifact" for step in steps)) + + def test_missing_output_blocks_activation(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _write_config( + Path(tmp), + """ +[site.protocols.tor] +enabled = true +renderer = "privacy-html" +daemon = "tor" +daemon_policy = "auto" +""", + ) + config = load_config(config_path) + + steps = activation_steps( + config, + platform_provider=platform_by_name("linux-user"), + daemon_probe=_probe(installed=False, running=False), + ) + + blocked = blocked_steps(steps) + self.assertTrue(any(step.stage == "output" for step in blocked)) + self.assertTrue(any(step.stage == "health" for step in blocked)) + + def test_apply_cli_requires_dry_run(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _write_config( + Path(tmp), + """ +[site.protocols.gemini] +enabled = true +renderer = "gemtext" +daemon = "agate" +daemon_policy = "external" +""", + ) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + status = main(["--config", str(config_path), "apply"]) + + self.assertEqual(1, status) + self.assertIn("live apply is not implemented", stderr.getvalue()) + + def test_apply_dry_run_can_write_review_artifacts(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config_path = _write_config( + root, + """ +[site.protocols.gemini] +enabled = true +renderer = "gemtext" +daemon = "agate" +daemon_policy = "external" +port = 1965 +""", + ) + output = root / "out/gemini" + 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", + "unknown", + ] + ) + + self.assertEqual(0, status) + output_text = stdout.getvalue() + self.assertIn("AMPG_APPLY_ARTIFACT", output_text) + self.assertIn("AMPG_APPLY_STEP", output_text) + self.assertIn("blocked=0", output_text) + artifact_line = next( + line for line in output_text.splitlines() if line.startswith("AMPG_APPLY_ARTIFACT") + ) + artifact_path = Path( + next( + part.removeprefix("path=") + for part in artifact_line.split() + if part.startswith("path=") + ) + ) + self.assertTrue(artifact_path.exists()) + + +if __name__ == "__main__": + unittest.main()