Plan managed state artifact copies

This commit is contained in:
jwinterm
2026-07-06 11:49:36 -04:00
parent b268e93634
commit 3063e61676
9 changed files with 223 additions and 9 deletions
+2 -1
View File
@@ -150,7 +150,8 @@ Captured transport addresses are written under `gateway.state_dir` by
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.
until the generated transport identity is captured or configured. With `--write-artifacts`,
apply also prints the reviewed config artifacts that would be copied into managed state.
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
+17 -2
View File
@@ -6,6 +6,7 @@ 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 .plan import plan_artifacts, write_plan_artifacts
from .platforms import PlatformProvider
from .status import (
@@ -257,5 +258,19 @@ def _transport_activation_status(status: TransportStatus) -> str:
return "ready"
def write_activation_artifacts(config: GatewayConfig) -> tuple[Path, ...]:
return tuple(artifact.path for artifact in write_plan_artifacts(config))
def write_activation_artifacts(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> tuple[Path, ...]:
paths = [artifact.path for artifact in write_plan_artifacts(config)]
paths.extend(
artifact.path
for artifact in write_install_artifacts(
config,
platform_provider=platform_provider,
daemon_probe=daemon_probe,
)
)
return tuple(paths)
+22 -2
View File
@@ -24,8 +24,10 @@ from .config import load_config
from .docsgen import generate_docs
from .health import HealthCheck, blocked_health_checks, health_plan
from .install_plan import (
InstallStateCopy,
InstallStep,
blocked_install_steps,
install_state_copies,
install_plan,
write_install_artifacts,
)
@@ -297,11 +299,14 @@ def _cmd_apply(config, args) -> int:
)
return 1
platform_provider = _platform_override(config, args)
if _write_artifacts_enabled(config, args):
for path in write_activation_artifacts(config):
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):
_print_install_state_copy(copy)
steps = activation_steps(config, platform_provider=_platform_override(config, args))
steps = activation_steps(config, platform_provider=platform_provider)
for step in steps:
_print_activation_step(step)
@@ -523,6 +528,21 @@ def _print_install_step(step: InstallStep) -> None:
)
def _print_install_state_copy(copy: InstallStateCopy) -> None:
print(
"AMPG_APPLY_STATE_COPY "
f"site={copy.site_id} "
f"protocol={copy.protocol} "
f"platform={copy.platform} "
f"kind={copy.kind} "
f"source=\"{_quote(str(copy.source))}\" "
f"target=\"{_quote(str(copy.target))}\" "
f"status={copy.status} "
f"command=\"{_quote(copy.command)}\" "
f"message=\"{_quote(copy.message)}\""
)
def _print_activation_step(step: ActivationStep) -> None:
print(
"AMPG_APPLY_STEP "
+93
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import shlex
from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .platforms import PlatformProvider, detect_platform
@@ -33,6 +34,19 @@ class InstallArtifact:
content: str
@dataclass(frozen=True)
class InstallStateCopy:
site_id: str
protocol: str
platform: str
kind: str
source: Path
target: Path
status: str
command: str
message: str
def install_plan(
config: GatewayConfig,
*,
@@ -104,6 +118,31 @@ def write_install_artifacts(
return artifacts
def install_state_copies(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallStateCopy]:
provider = platform_provider or detect_platform()
statuses = gateway_status(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
copies: list[InstallStateCopy] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
copies.extend(_protocol_state_copies(config, site, protocol, provider, status))
return copies
def _protocol_install_steps(
config: GatewayConfig,
site: SiteConfig,
@@ -279,6 +318,52 @@ def _protocol_install_artifacts(
return artifacts
def _protocol_state_copies(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
) -> list[InstallStateCopy]:
copies: list[InstallStateCopy] = []
for artifact in _protocol_install_artifacts(config, site, protocol, provider, status):
target = _state_copy_target(config, site, protocol, artifact)
if target is None:
continue
source_exists = artifact.path.exists()
copies.append(
InstallStateCopy(
site_id=site.id,
protocol=protocol.name,
platform=provider.name,
kind=artifact.kind,
source=artifact.path,
target=target,
status="planned" if source_exists else "review",
command=_copy_command(provider, artifact.path, target),
message=(
"copy reviewed artifact into managed state before daemon start"
if source_exists
else "review artifact is missing; rerun with --write-artifacts first"
),
)
)
return copies
def _state_copy_target(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
artifact: InstallArtifact,
) -> Path | None:
if artifact.kind == "daemon-config":
return daemon_config_path(config, site, protocol)
if artifact.kind == "loopback-config":
return protocol_state_dir(config, site, protocol) / artifact.path.name
return None
def _package_step(
site: SiteConfig,
protocol: ProtocolConfig,
@@ -334,6 +419,14 @@ def _mkdir_command(provider: PlatformProvider, path: Path) -> str:
return f"{prefix}mkdir -p {path}"
def _copy_command(provider: PlatformProvider, source: Path, target: Path) -> str:
prefix = "sudo " if provider.can_write_system_config else ""
parent = shlex.quote(str(target.parent))
source_text = shlex.quote(str(source))
target_text = shlex.quote(str(target))
return f"{prefix}mkdir -p {parent} && {prefix}cp {source_text} {target_text}"
def _supervisor_command(
provider: PlatformProvider,
site: SiteConfig,
+3 -1
View File
@@ -112,7 +112,9 @@ render-only environment while making daemon ownership decisions explicit.
`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, plus an address step when AMPG must capture or confirm a generated transport
identity. Blocked steps stop the command before any live apply path can touch services.
identity. With `--write-artifacts`, it also prints planned copies from reviewed artifacts
into managed state. 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
+2 -1
View File
@@ -85,7 +85,8 @@ 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 but still does not install
writes reviewable config snippets to the configured plan root, then prints the planned
copies from those reviewed artifacts into `gateway.state_dir`. It still does not install
or reload services.
`--protocol` scopes operational commands to selected enabled protocols. A clearnet
+4 -2
View File
@@ -38,5 +38,7 @@ Current managed defaults:
## Safety
AMPG must never delete sensitive contract paths without an explicit destructive command.
Live apply should copy reviewed config artifacts into their contract `daemon-config`
paths, start daemons, capture public addresses, and then run published health checks.
`apply --dry-run --write-artifacts` prints `AMPG_APPLY_STATE_COPY` rows that show which
reviewed artifacts would be copied into managed state. Live apply should perform those
reviewed copies, start daemons, capture public addresses, and then run published health
checks.
+39
View File
@@ -209,6 +209,45 @@ port = 1965
)
self.assertTrue(artifact_path.exists())
def test_apply_dry_run_write_artifacts_prints_state_copy_plan(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_STATE_COPY site=example protocol=i2p", output_text)
self.assertIn("kind=daemon-config", output_text)
self.assertIn("kind=loopback-config", output_text)
self.assertIn(str(root / ".ampg/state/example/i2p/i2pd-tunnels.conf"), output_text)
self.assertIn(str(root / ".ampg/state/example/i2p/nginx-loopback.conf"), output_text)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -10,6 +10,7 @@ from ampg.install_plan import (
blocked_install_steps,
install_artifacts,
install_plan,
install_state_copies,
write_install_artifacts,
)
from ampg.platforms import platform_by_name
@@ -263,6 +264,46 @@ daemon_policy = "auto"
).exists()
)
def test_state_copy_plan_maps_review_artifacts_to_managed_state(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config = load_config(
_write_config(
root,
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
daemon = "i2pd"
daemon_policy = "auto"
""",
)
)
write_install_artifacts(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
copies = install_state_copies(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
by_kind = {copy.kind: copy for copy in copies}
self.assertEqual({"daemon-config", "loopback-config"}, set(by_kind))
self.assertEqual("planned", by_kind["daemon-config"].status)
self.assertEqual(
root / ".ampg/state/example/i2p/i2pd-tunnels.conf",
by_kind["daemon-config"].target,
)
self.assertEqual(
root / ".ampg/state/example/i2p/nginx-loopback.conf",
by_kind["loopback-config"].target,
)
self.assertIn("cp", by_kind["daemon-config"].command)
def test_install_plan_cli_can_write_managed_artifacts(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)