Add apply preflight gate

This commit is contained in:
jwinterm
2026-07-06 12:29:08 -04:00
parent a921f158c3
commit fbfddbf2bf
9 changed files with 272 additions and 30 deletions
+11 -8
View File
@@ -57,8 +57,9 @@ ampg apply --dry-run
``` ```
`plan` prints rendered output paths, daemon decisions, port bindings, config diffs, and `plan` prints rendered output paths, daemon decisions, port bindings, config diffs, and
operator actions. The current `apply --dry-run` prints the activation sequence without operator actions. The current `apply --dry-run` prints the activation sequence and a
changing services. Live apply support will perform only approved changes. preflight verdict without changing services. Live apply support will perform only
approved changes.
## Interaction tiers ## 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 `addresses capture` or `addresses set`. Fixture manifests and health plans use explicit
config first, then captured addresses, then deterministic placeholders. config first, then captured addresses, then deterministic placeholders.
`apply --dry-run` includes an address stage. Placeholder addresses remain review items `apply --dry-run` includes an address stage and an `AMPG_APPLY_PREFLIGHT` gate.
until the generated transport identity is captured or configured. With `--write-artifacts`, Placeholder addresses remain review items until the generated transport identity is
apply also prints reviewed config artifacts that would be copied into managed state and captured or configured. With `--write-artifacts`, apply also prints reviewed config
the supervisor services that would be registered or started. 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 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 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 `ampg build`; inspect generated variants.
- [ ] Run AMPB fixture checks against generated manifests. - [ ] Run AMPB fixture checks against generated manifests.
- [ ] Run `ampg health-plan`; review post-start transport checks. - [ ] Run `ampg health-plan`; review post-start transport checks.
- [ ] Run `ampg apply --dry-run`; review the activation sequence. - [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate.
- [ ] Run live apply after the activation sequence is clean. - [ ] Run live apply after the preflight gate is ready.
+107 -1
View File
@@ -6,7 +6,11 @@ import shlex
from .addresses import AddressRecord, address_registry_path, effective_address_records from .addresses import AddressRecord, address_registry_path, effective_address_records
from .config import GatewayConfig, ProtocolConfig, SiteConfig 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 .plan import plan_artifacts, write_plan_artifacts
from .platforms import PlatformProvider from .platforms import PlatformProvider
from .status import ( from .status import (
@@ -29,6 +33,30 @@ class ActivationStep:
command: str = "-" 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( def activation_steps(
config: GatewayConfig, config: GatewayConfig,
*, *,
@@ -93,6 +121,45 @@ def blocked_steps(steps: list[ActivationStep]) -> list[ActivationStep]:
return [step for step in steps if step.status == "blocked"] 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( def _protocol_steps(
config: GatewayConfig, config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
@@ -258,6 +325,45 @@ def _transport_activation_status(status: TransportStatus) -> str:
return "ready" 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( def write_activation_artifacts(
config: GatewayConfig, config: GatewayConfig,
*, *,
+51 -6
View File
@@ -5,8 +5,11 @@ from pathlib import Path
import sys import sys
from .activation import ( from .activation import (
ApplyPreflight,
ApplyPreflightItem,
ActivationStep, ActivationStep,
activation_steps, activation_steps,
apply_preflight,
blocked_steps, blocked_steps,
write_activation_artifacts, write_activation_artifacts,
) )
@@ -302,19 +305,33 @@ def _cmd_apply(config, args) -> int:
return 1 return 1
platform_provider = _platform_override(config, args) 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): for path in write_activation_artifacts(config, platform_provider=platform_provider):
print(f"AMPG_APPLY_ARTIFACT path={path} status=written") 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) _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) _print_supervisor_action(action)
steps = activation_steps(config, platform_provider=platform_provider) steps = activation_steps(config, platform_provider=platform_provider)
for step in steps: for step in steps:
_print_activation_step(step) _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( print(
"AMPG_APPLY_SUMMARY " "AMPG_APPLY_SUMMARY "
"mode=dry-run " "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"ready={sum(1 for step in steps if step.status == 'ready')} "
f"review={sum(1 for step in steps if step.status == 'review')} " 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"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: 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: def _print_activation_step(step: ActivationStep) -> None:
print( print(
"AMPG_APPLY_STEP " "AMPG_APPLY_STEP "
+3 -2
View File
@@ -113,8 +113,9 @@ render-only environment while making daemon ownership decisions explicit.
activation sequence. Each enabled protocol gets output, artifact, daemon, and health 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 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 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 into managed state and planned supervisor start/reload actions. The command emits an
command before any live apply path can touch services. `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 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 blocked during activation, because AMPG must not point a transport at an unbuilt output
+3 -3
View File
@@ -49,9 +49,9 @@ python3 tools/docs_check.py
## Public/private boundary ## Public/private boundary
Public docs include reusable contracts, generated references, and concise operator-facing 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, guidance. Private docs are ignored under `docs/private/` and are the place for
internal discussion, deployment inventory, working notes, tracking, timelines, and any non-public planning, deployment inventory, working notes, and real host-specific
real host-specific material. material.
Public docs can include example domains, ports, and fake keys. Real daemon keys, 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 production paths with secrets, private host inventory, and internal planning notes belong
+6 -4
View File
@@ -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 `apply --dry-run` prints the activation sequence for each enabled protocol: generated
output readiness, config artifacts to review, daemon action, address capture or reuse, 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` and post-apply health checks. It also prints `AMPG_APPLY_PREFLIGHT` plus blocked or
writes reviewable config snippets to the configured plan root, then prints the planned review preflight items across activation, managed-state copies, and supervisor actions.
copies from those reviewed artifacts into `gateway.state_dir` and the supervisor services It exits nonzero when the preflight status is blocked. `--write-artifacts` writes
that would be registered or started. It still does not install or reload services. 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 `--protocol` scopes operational commands to selected enabled protocols. A clearnet
`adopt` failure will block a full activation run, but it will not block `adopt` failure will block a full activation run, but it will not block
+1 -2
View File
@@ -3,8 +3,7 @@
> Status: draft | Updated 2026-07-06 | Applies to: AMPG route exposure policy > Status: draft | Updated 2026-07-06 | Applies to: AMPG route exposure policy
Interaction capabilities define what AMPG may expose for dynamic applications. They are Interaction capabilities define what AMPG may expose for dynamic applications. They are
public, generic rules. Project strategy, target names, and implementation notes belong in public, generic rules. Non-public project notes belong in `docs/private/`.
`docs/private/`.
## Generated Policy ## Generated Policy
+4 -3
View File
@@ -40,6 +40,7 @@ Current managed defaults:
AMPG must never delete sensitive contract paths without an explicit destructive command. 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 `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 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 for the services that would be registered or started. The accompanying
reviewed copies, start daemons, capture public addresses, and then run published health `AMPG_APPLY_PREFLIGHT` verdict includes those rows so live apply can refuse mutation when
checks. state or supervisor inputs still need review. Live apply should perform reviewed copies,
start daemons, capture public addresses, and then run published health checks.
+86 -1
View File
@@ -4,9 +4,10 @@ import tempfile
import unittest import unittest
from pathlib import Path 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.cli import main
from ampg.config import load_config 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.platforms import platform_by_name
from ampg.status import DaemonProbe 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 == "output" for step in blocked))
self.assertTrue(any(step.stage == "health" 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): def test_apply_cli_requires_dry_run(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
config_path = _write_config( 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-i2pd", output_text)
self.assertIn("service=ampg-example-i2p-nginx-loopback", 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__": if __name__ == "__main__":
unittest.main() unittest.main()