mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Plan managed state artifact copies
This commit is contained in:
@@ -150,7 +150,8 @@ Captured transport addresses are written under `gateway.state_dir` by
|
|||||||
config first, then captured addresses, then deterministic placeholders.
|
config first, then captured addresses, then deterministic placeholders.
|
||||||
|
|
||||||
`apply --dry-run` includes an address stage. Placeholder addresses remain review items
|
`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
|
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
|
lets a full site config build or activate only Tor, only I2P, or a selected subset without
|
||||||
|
|||||||
+17
-2
@@ -6,6 +6,7 @@ import shlex
|
|||||||
|
|
||||||
from .addresses import AddressRecord, address_registry_path, effective_address_records
|
from .addresses import AddressRecord, address_registry_path, effective_address_records
|
||||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||||
|
from .install_plan import write_install_artifacts
|
||||||
from .plan import plan_artifacts, write_plan_artifacts
|
from .plan import plan_artifacts, write_plan_artifacts
|
||||||
from .platforms import PlatformProvider
|
from .platforms import PlatformProvider
|
||||||
from .status import (
|
from .status import (
|
||||||
@@ -257,5 +258,19 @@ def _transport_activation_status(status: TransportStatus) -> str:
|
|||||||
return "ready"
|
return "ready"
|
||||||
|
|
||||||
|
|
||||||
def write_activation_artifacts(config: GatewayConfig) -> tuple[Path, ...]:
|
def write_activation_artifacts(
|
||||||
return tuple(artifact.path for artifact in write_plan_artifacts(config))
|
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
@@ -24,8 +24,10 @@ from .config import load_config
|
|||||||
from .docsgen import generate_docs
|
from .docsgen import generate_docs
|
||||||
from .health import HealthCheck, blocked_health_checks, health_plan
|
from .health import HealthCheck, blocked_health_checks, health_plan
|
||||||
from .install_plan import (
|
from .install_plan import (
|
||||||
|
InstallStateCopy,
|
||||||
InstallStep,
|
InstallStep,
|
||||||
blocked_install_steps,
|
blocked_install_steps,
|
||||||
|
install_state_copies,
|
||||||
install_plan,
|
install_plan,
|
||||||
write_install_artifacts,
|
write_install_artifacts,
|
||||||
)
|
)
|
||||||
@@ -297,11 +299,14 @@ def _cmd_apply(config, args) -> int:
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
platform_provider = _platform_override(config, args)
|
||||||
if _write_artifacts_enabled(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")
|
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:
|
for step in steps:
|
||||||
_print_activation_step(step)
|
_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:
|
def _print_activation_step(step: ActivationStep) -> None:
|
||||||
print(
|
print(
|
||||||
"AMPG_APPLY_STEP "
|
"AMPG_APPLY_STEP "
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
|
||||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||||
from .platforms import PlatformProvider, detect_platform
|
from .platforms import PlatformProvider, detect_platform
|
||||||
@@ -33,6 +34,19 @@ class InstallArtifact:
|
|||||||
content: str
|
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(
|
def install_plan(
|
||||||
config: GatewayConfig,
|
config: GatewayConfig,
|
||||||
*,
|
*,
|
||||||
@@ -104,6 +118,31 @@ def write_install_artifacts(
|
|||||||
return 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(
|
def _protocol_install_steps(
|
||||||
config: GatewayConfig,
|
config: GatewayConfig,
|
||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
@@ -279,6 +318,52 @@ def _protocol_install_artifacts(
|
|||||||
return 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(
|
def _package_step(
|
||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
protocol: ProtocolConfig,
|
protocol: ProtocolConfig,
|
||||||
@@ -334,6 +419,14 @@ def _mkdir_command(provider: PlatformProvider, path: Path) -> str:
|
|||||||
return f"{prefix}mkdir -p {path}"
|
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(
|
def _supervisor_command(
|
||||||
provider: PlatformProvider,
|
provider: PlatformProvider,
|
||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
|
|||||||
@@ -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
|
`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
|
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
|
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
|
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
|
blocked during activation, because AMPG must not point a transport at an unbuilt output
|
||||||
|
|||||||
@@ -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
|
`apply --dry-run` prints the activation sequence for each enabled protocol: generated
|
||||||
output readiness, config artifacts to review, daemon action, address capture or reuse,
|
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`
|
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.
|
or reload services.
|
||||||
|
|
||||||
`--protocol` scopes operational commands to selected enabled protocols. A clearnet
|
`--protocol` scopes operational commands to selected enabled protocols. A clearnet
|
||||||
|
|||||||
@@ -38,5 +38,7 @@ Current managed defaults:
|
|||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
AMPG must never delete sensitive contract paths without an explicit destructive command.
|
AMPG must never delete sensitive contract paths without an explicit destructive command.
|
||||||
Live apply should copy reviewed config artifacts into their contract `daemon-config`
|
`apply --dry-run --write-artifacts` prints `AMPG_APPLY_STATE_COPY` rows that show which
|
||||||
paths, start daemons, capture public addresses, and then run published health checks.
|
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.
|
||||||
|
|||||||
@@ -209,6 +209,45 @@ port = 1965
|
|||||||
)
|
)
|
||||||
self.assertTrue(artifact_path.exists())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from ampg.install_plan import (
|
|||||||
blocked_install_steps,
|
blocked_install_steps,
|
||||||
install_artifacts,
|
install_artifacts,
|
||||||
install_plan,
|
install_plan,
|
||||||
|
install_state_copies,
|
||||||
write_install_artifacts,
|
write_install_artifacts,
|
||||||
)
|
)
|
||||||
from ampg.platforms import platform_by_name
|
from ampg.platforms import platform_by_name
|
||||||
@@ -263,6 +264,46 @@ daemon_policy = "auto"
|
|||||||
).exists()
|
).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):
|
def test_install_plan_cli_can_write_managed_artifacts(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
|
|||||||
Reference in New Issue
Block a user