Add dry-run apply activation plan

This commit is contained in:
jwinterm
2026-07-06 04:54:03 -04:00
parent 27f3805c19
commit 83847b205b
6 changed files with 464 additions and 3 deletions
+189
View File
@@ -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))
+70
View File
@@ -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(