From fbfddbf2bf242053049af06933150355362f7cd6 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 12:29:08 -0400 Subject: [PATCH] Add apply preflight gate --- README.md | 19 +++--- ampg/activation.py | 108 ++++++++++++++++++++++++++++++- ampg/cli.py | 57 ++++++++++++++-- docs/architecture.md | 5 +- docs/autodocs.md | 6 +- docs/daemon-management.md | 10 +-- docs/interaction-capabilities.md | 3 +- docs/state-contract.md | 7 +- tests/test_activation.py | 87 ++++++++++++++++++++++++- 9 files changed, 272 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 511342d..e2547cf 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,9 @@ ampg apply --dry-run ``` `plan` prints rendered output paths, daemon decisions, port bindings, config diffs, and -operator actions. The current `apply --dry-run` prints the activation sequence without -changing services. Live apply support will perform only approved changes. +operator actions. The current `apply --dry-run` prints the activation sequence and a +preflight verdict without changing services. Live apply support will perform only +approved changes. ## Interaction tiers @@ -149,10 +150,12 @@ 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. With `--write-artifacts`, -apply also prints reviewed config artifacts that would be copied into managed state and -the supervisor services that would be registered or started. +`apply --dry-run` includes an address stage and an `AMPG_APPLY_PREFLIGHT` gate. +Placeholder addresses remain review items until the generated transport identity is +captured or configured. With `--write-artifacts`, apply also prints reviewed config +artifacts that would be copied into managed state and the supervisor services that would +be registered or started. The preflight gate reports `blocked`, `review`, or `ready` +across activation steps, managed-state copies, and supervisor actions. 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 @@ -172,5 +175,5 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] Run `ampg build`; inspect generated variants. - [ ] Run AMPB fixture checks against generated manifests. - [ ] Run `ampg health-plan`; review post-start transport checks. -- [ ] Run `ampg apply --dry-run`; review the activation sequence. -- [ ] Run live apply after the activation sequence is clean. +- [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate. +- [ ] Run live apply after the preflight gate is ready. diff --git a/ampg/activation.py b/ampg/activation.py index 564dc58..313ac81 100644 --- a/ampg/activation.py +++ b/ampg/activation.py @@ -6,7 +6,11 @@ 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 .install_plan import ( + InstallStateCopy, + InstallSupervisorAction, + write_install_artifacts, +) from .plan import plan_artifacts, write_plan_artifacts from .platforms import PlatformProvider from .status import ( @@ -29,6 +33,30 @@ class ActivationStep: command: str = "-" +@dataclass(frozen=True) +class ApplyPreflightItem: + phase: str + site_id: str + protocol: str + status: str + action: str + target: str + command: str + message: str + + +@dataclass(frozen=True) +class ApplyPreflight: + status: str + items: tuple[ApplyPreflightItem, ...] + ready: int + review: int + planned: int + blocked: int + skipped: int + message: str + + def activation_steps( config: GatewayConfig, *, @@ -93,6 +121,45 @@ def blocked_steps(steps: list[ActivationStep]) -> list[ActivationStep]: return [step for step in steps if step.status == "blocked"] +def apply_preflight( + *, + activation: list[ActivationStep], + state_copies: list[InstallStateCopy], + supervisor_actions: list[InstallSupervisorAction], +) -> ApplyPreflight: + items = tuple( + [ + *(_activation_preflight_item(step) for step in activation), + *(_state_copy_preflight_item(copy) for copy in state_copies), + *(_supervisor_preflight_item(action) for action in supervisor_actions), + ] + ) + ready = sum(1 for item in items if item.status == "ready") + review = sum(1 for item in items if item.status == "review") + planned = sum(1 for item in items if item.status == "planned") + blocked = sum(1 for item in items if item.status == "blocked") + skipped = sum(1 for item in items if item.status == "skipped") + if blocked: + status = "blocked" + message = "live apply is blocked until reported items are fixed" + elif review: + status = "review" + message = "live apply requires operator review before mutation" + else: + status = "ready" + message = "live apply preflight is clean" + return ApplyPreflight( + status=status, + items=items, + ready=ready, + review=review, + planned=planned, + blocked=blocked, + skipped=skipped, + message=message, + ) + + def _protocol_steps( config: GatewayConfig, site: SiteConfig, @@ -258,6 +325,45 @@ def _transport_activation_status(status: TransportStatus) -> str: return "ready" +def _activation_preflight_item(step: ActivationStep) -> ApplyPreflightItem: + return ApplyPreflightItem( + phase=f"activation:{step.stage}", + site_id=step.site_id, + protocol=step.protocol, + status=step.status, + action=step.action, + target=step.target, + command=step.command, + message=step.message, + ) + + +def _state_copy_preflight_item(copy: InstallStateCopy) -> ApplyPreflightItem: + return ApplyPreflightItem( + phase="state-copy", + site_id=copy.site_id, + protocol=copy.protocol, + status=copy.status, + action=copy.kind, + target=str(copy.target), + command=copy.command, + message=copy.message, + ) + + +def _supervisor_preflight_item(action: InstallSupervisorAction) -> ApplyPreflightItem: + return ApplyPreflightItem( + phase="supervisor", + site_id=action.site_id, + protocol=action.protocol, + status=action.status, + action=action.kind, + target=action.service, + command=action.command, + message=action.message, + ) + + def write_activation_artifacts( config: GatewayConfig, *, diff --git a/ampg/cli.py b/ampg/cli.py index 2fd172b..170656e 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -5,8 +5,11 @@ from pathlib import Path import sys from .activation import ( + ApplyPreflight, + ApplyPreflightItem, ActivationStep, activation_steps, + apply_preflight, blocked_steps, write_activation_artifacts, ) @@ -302,19 +305,33 @@ def _cmd_apply(config, args) -> int: return 1 platform_provider = _platform_override(config, args) - if _write_artifacts_enabled(config, args): + write_artifacts = _write_artifacts_enabled(config, args) + if write_artifacts: 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): + + state_copies = install_state_copies(config, platform_provider=platform_provider) + supervisor_actions = install_supervisor_actions(config, platform_provider=platform_provider) + if write_artifacts: + for copy in state_copies: _print_install_state_copy(copy) - for action in install_supervisor_actions(config, platform_provider=platform_provider): + for action in supervisor_actions: _print_supervisor_action(action) steps = activation_steps(config, platform_provider=platform_provider) for step in steps: _print_activation_step(step) - blocked = blocked_steps(steps) + preflight = apply_preflight( + activation=steps, + state_copies=state_copies, + supervisor_actions=supervisor_actions, + ) + for item in preflight.items: + if item.status in {"blocked", "review"}: + _print_apply_preflight_item(item) + _print_apply_preflight(preflight) + print( "AMPG_APPLY_SUMMARY " "mode=dry-run " @@ -323,9 +340,9 @@ def _cmd_apply(config, args) -> int: 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)}" + f"blocked={len(blocked_steps(steps))}" ) - return 1 if blocked else 0 + return 1 if preflight.status == "blocked" else 0 def _cmd_install_plan(config, args) -> int: @@ -562,6 +579,34 @@ def _print_supervisor_action(action: InstallSupervisorAction) -> None: ) +def _print_apply_preflight(preflight: ApplyPreflight) -> None: + print( + "AMPG_APPLY_PREFLIGHT " + f"status={preflight.status} " + f"items={len(preflight.items)} " + f"ready={preflight.ready} " + f"review={preflight.review} " + f"planned={preflight.planned} " + f"skipped={preflight.skipped} " + f"blocked={preflight.blocked} " + f"message=\"{_quote(preflight.message)}\"" + ) + + +def _print_apply_preflight_item(item: ApplyPreflightItem) -> None: + print( + "AMPG_APPLY_PREFLIGHT_ITEM " + f"phase={item.phase} " + f"site={item.site_id} " + f"protocol={item.protocol} " + f"status={item.status} " + f"action={item.action} " + f"target=\"{_quote(item.target)}\" " + f"command=\"{_quote(item.command)}\" " + f"message=\"{_quote(item.message)}\"" + ) + + def _print_activation_step(step: ActivationStep) -> None: print( "AMPG_APPLY_STEP " diff --git a/docs/architecture.md b/docs/architecture.md index 8518746..57d8310 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,8 +113,9 @@ 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 and planned supervisor start/reload actions. Blocked steps stop the -command before any live apply path can touch services. +into managed state and planned supervisor start/reload actions. The command emits an +`AMPG_APPLY_PREFLIGHT` verdict across activation, state-copy, and supervisor rows. +Blocked preflight items 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/autodocs.md b/docs/autodocs.md index 118c65c..7828c7a 100644 --- a/docs/autodocs.md +++ b/docs/autodocs.md @@ -49,9 +49,9 @@ python3 tools/docs_check.py ## Public/private boundary Public docs include reusable contracts, generated references, and concise operator-facing -guidance. Private docs are ignored under `docs/private/` and are the place for strategy, -internal discussion, deployment inventory, working notes, tracking, timelines, and any -real host-specific material. +guidance. Private docs are ignored under `docs/private/` and are the place for +non-public planning, deployment inventory, working notes, and real host-specific +material. Public docs can include example domains, ports, and fake keys. Real daemon keys, production paths with secrets, private host inventory, and internal planning notes belong diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 9a213d7..0db615a 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -84,10 +84,12 @@ 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, then prints the planned -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. +and post-apply health checks. It also prints `AMPG_APPLY_PREFLIGHT` plus blocked or +review preflight items across activation, managed-state copies, and supervisor actions. +It exits nonzero when the preflight status 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` 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/interaction-capabilities.md b/docs/interaction-capabilities.md index 97a1f56..c9d788d 100644 --- a/docs/interaction-capabilities.md +++ b/docs/interaction-capabilities.md @@ -3,8 +3,7 @@ > Status: draft | Updated 2026-07-06 | Applies to: AMPG route exposure policy Interaction capabilities define what AMPG may expose for dynamic applications. They are -public, generic rules. Project strategy, target names, and implementation notes belong in -`docs/private/`. +public, generic rules. Non-public project notes belong in `docs/private/`. ## Generated Policy diff --git a/docs/state-contract.md b/docs/state-contract.md index bd66a0e..98fbb9d 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -40,6 +40,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 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. +for the services that would be registered or started. The accompanying +`AMPG_APPLY_PREFLIGHT` verdict includes those rows so live apply can refuse mutation when +state or supervisor inputs still need review. Live apply should perform 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 031271c..a32c05d 100644 --- a/tests/test_activation.py +++ b/tests/test_activation.py @@ -4,9 +4,10 @@ import tempfile import unittest from pathlib import Path -from ampg.activation import activation_steps, blocked_steps +from ampg.activation import activation_steps, apply_preflight, blocked_steps from ampg.cli import main from ampg.config import load_config +from ampg.install_plan import install_state_copies, install_supervisor_actions from ampg.platforms import platform_by_name from ampg.status import DaemonProbe @@ -139,6 +140,52 @@ daemon_policy = "auto" self.assertTrue(any(step.stage == "output" for step in blocked)) self.assertTrue(any(step.stage == "health" for step in blocked)) + def test_apply_preflight_reports_blocked_and_review_rows(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _write_config( + Path(tmp), + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +""", + ) + config = load_config(config_path) + platform_provider = platform_by_name("android-termux") + + preflight = apply_preflight( + activation=activation_steps( + config, + platform_provider=platform_provider, + daemon_probe=_probe(installed=False, running=False), + ), + state_copies=install_state_copies( + config, + platform_provider=platform_provider, + daemon_probe=_probe(installed=False, running=False), + ), + supervisor_actions=install_supervisor_actions( + config, + platform_provider=platform_provider, + daemon_probe=_probe(installed=False, running=False), + ), + ) + + self.assertEqual("blocked", preflight.status) + self.assertGreaterEqual(preflight.blocked, 1) + self.assertGreaterEqual(preflight.review, 1) + self.assertTrue( + any( + item.phase == "activation:output" and item.status == "blocked" + for item in preflight.items + ) + ) + self.assertTrue( + any(item.phase == "state-copy" and item.status == "review" for item in preflight.items) + ) + def test_apply_cli_requires_dry_run(self): with tempfile.TemporaryDirectory() as tmp: config_path = _write_config( @@ -251,6 +298,44 @@ daemon_policy = "auto" self.assertIn("service=ampg-example-i2p-i2pd", output_text) self.assertIn("service=ampg-example-i2p-nginx-loopback", output_text) + def test_apply_dry_run_prints_preflight_gate(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_PREFLIGHT status=review", output_text) + self.assertIn("AMPG_APPLY_PREFLIGHT_ITEM phase=activation:artifact", output_text) + self.assertIn("AMPG_APPLY_PREFLIGHT_ITEM phase=activation:address", output_text) + self.assertIn('message="live apply requires operator review before mutation"', output_text) + if __name__ == "__main__": unittest.main()