mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add dry-run apply activation plan
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user