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
+7 -3
View File
@@ -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.
+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(
+10
View File
@@ -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.
+8
View File
@@ -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.
+180
View File
@@ -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("<h1>Hello</h1>", 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()