mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add apply preflight gate
This commit is contained in:
@@ -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.
|
||||
|
||||
+107
-1
@@ -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,
|
||||
*,
|
||||
|
||||
+51
-6
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user