Write managed install artifacts

This commit is contained in:
jwinterm
2026-07-06 05:50:12 -04:00
parent 035ab4ac81
commit 1641f2009d
6 changed files with 563 additions and 3 deletions
+5
View File
@@ -105,6 +105,7 @@ 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 install-plan --profile mobile-i2p
python3 -m ampg --config examples/wownero.gateway.toml install-plan --profile mobile-i2p --write-artifacts
python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run
python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --profile mobile-i2p
python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --protocol i2p
@@ -132,6 +133,10 @@ that marker is present.
Plan artifacts are written under `dist/ampg-plan/` only when `--write-artifacts` is
passed. They are reviewable snippets, not installed daemon config.
Managed install artifacts are also written under `dist/ampg-plan/` when
`install-plan --write-artifacts` is passed. They include reviewable daemon configs and
platform supervisor files, but are not installed or started.
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
unselected transports blocking the run.
+26 -2
View File
@@ -14,7 +14,12 @@ from .audit import audit_gateway
from .build import build_gateway
from .config import load_config
from .docsgen import generate_docs
from .install_plan import InstallStep, blocked_install_steps, install_plan
from .install_plan import (
InstallStep,
blocked_install_steps,
install_plan,
write_install_artifacts,
)
from .manifest import write_fixture_manifests
from .plan import plan_gateway, write_plan_artifacts
from .platforms import PLATFORM_NAMES, platform_by_name
@@ -95,6 +100,11 @@ def main(argv: list[str] | None = None) -> int:
choices=PLATFORM_NAMES,
help="Override platform detection for install planning.",
)
install_plan_parser.add_argument(
"--write-artifacts",
action="store_true",
help="Write managed daemon config and supervisor artifacts to the plan root.",
)
build_parser = subcommands.add_parser("build", help="Build enabled protocol outputs.")
_add_target_selection(build_parser)
fixture_parser = subcommands.add_parser("manifest", help="Write AMPB fixture manifests.")
@@ -232,7 +242,21 @@ def _cmd_apply(config, args) -> int:
def _cmd_install_plan(config, args) -> int:
steps = install_plan(config, platform_provider=_platform_override(config, args))
platform_provider = _platform_override(config, args)
if _write_artifacts_enabled(config, args):
for artifact in write_install_artifacts(
config,
platform_provider=platform_provider,
):
print(
"AMPG_INSTALL_ARTIFACT "
f"site={artifact.site_id} "
f"protocol={artifact.protocol} "
f"platform={artifact.platform} "
f"kind={artifact.kind} "
f"path={artifact.path}"
)
steps = install_plan(config, platform_provider=platform_provider)
for step in steps:
_print_install_step(step)
blocked = blocked_install_steps(steps)
+382
View File
@@ -22,6 +22,16 @@ class InstallStep:
message: str
@dataclass(frozen=True)
class InstallArtifact:
site_id: str
protocol: str
platform: str
kind: str
path: Path
content: str
def install_plan(
config: GatewayConfig,
*,
@@ -51,6 +61,48 @@ def blocked_install_steps(steps: list[InstallStep]) -> list[InstallStep]:
return [step for step in steps if step.status == "blocked"]
def install_artifacts(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallArtifact]:
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
}
artifacts: list[InstallArtifact] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
artifacts.extend(_protocol_install_artifacts(site, protocol, provider, status))
return artifacts
def write_install_artifacts(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallArtifact]:
artifacts = install_artifacts(
config,
platform_provider=platform_provider,
daemon_probe=daemon_probe,
)
for artifact in artifacts:
artifact.path.parent.mkdir(parents=True, exist_ok=True)
artifact.path.write_text(artifact.content, encoding="utf-8")
return artifacts
def _protocol_install_steps(
site: SiteConfig,
protocol: ProtocolConfig,
@@ -159,6 +211,69 @@ def _protocol_install_steps(
]
def _protocol_install_artifacts(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
) -> list[InstallArtifact]:
if status.action != "manage-owned" or status.status == "error":
return []
artifacts = [
_artifact(
site,
protocol,
provider,
kind="daemon-config",
filename=_daemon_config_filename(protocol),
content=_daemon_config_content(site, protocol, provider),
),
_artifact(
site,
protocol,
provider,
kind="daemon-supervisor",
filename=_supervisor_filename(provider, protocol.daemon),
content=_supervisor_content(
site,
protocol,
provider,
service_suffix=protocol.daemon,
command=_daemon_start_command(site, protocol, provider),
),
),
]
if protocol.name in {"tor", "i2p"}:
artifacts.extend(
(
_artifact(
site,
protocol,
provider,
kind="loopback-config",
filename="nginx-loopback.conf",
content=_managed_nginx_loopback_content(site, protocol, provider),
),
_artifact(
site,
protocol,
provider,
kind="loopback-supervisor",
filename=_supervisor_filename(provider, "nginx-loopback"),
content=_supervisor_content(
site,
protocol,
provider,
service_suffix="nginx-loopback",
command=_loopback_start_command(site, protocol, provider),
),
),
)
)
return artifacts
def _package_step(
site: SiteConfig,
protocol: ProtocolConfig,
@@ -239,10 +354,277 @@ def _state_dir(
return provider.state_root / site.id / protocol.name
def _artifact_dir(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> Path:
return site.outputs.plan_root / site.id / protocol.name / "managed" / provider.name
def _service_name(site: SiteConfig, protocol: ProtocolConfig) -> str:
return f"ampg-{site.id}-{protocol.name}"
def _artifact_service_name(
site: SiteConfig,
protocol: ProtocolConfig,
service_suffix: str,
) -> str:
return f"{_service_name(site, protocol)}-{service_suffix}"
def _daemon_config_filename(protocol: ProtocolConfig) -> str:
if protocol.name == "tor":
return "torrc"
if protocol.name == "i2p":
return "i2pd-tunnels.conf"
if protocol.name == "gemini":
return "agate-plan.txt"
if protocol.name == "clearnet":
return "nginx-server.conf"
return f"{protocol.daemon}-plan.txt"
def _daemon_config_content(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(provider, site, protocol)
if protocol.name == "tor":
port = int(protocol.options.get("local_port", 18080))
hidden_service_dir = state_dir / "hidden-service"
return f"""# Generated by AMPG. Review before installing.
DataDirectory {state_dir / "data"}
HiddenServiceDir {hidden_service_dir}/
HiddenServicePort 80 127.0.0.1:{port}
Log notice file {state_dir / "tor.log"}
"""
if protocol.name == "i2p":
port = int(protocol.options.get("local_port", 18081))
key_file = protocol.options.get("keys_file", f"{site.id}-web.dat")
tunnel_name = protocol.options.get("tunnel_name", f"{site.id}-web")
return f"""# Generated by AMPG. Review before installing.
[{tunnel_name}]
type = server
host = 127.0.0.1
port = {port}
keys = {state_dir / str(key_file)}
"""
if protocol.name == "gemini":
port = int(protocol.options.get("port", 1965))
return f"""# Generated by AMPG. Review before installing.
daemon = agate
hostname = {site.domain}
listen = [::]:{port}
content_root = {site.outputs.root / protocol.name}
certificate = {state_dir / f"{site.id}.crt"}
private_key = {state_dir / f"{site.id}.key"}
"""
if protocol.name == "clearnet":
return _managed_nginx_server_content(site, protocol, provider)
return f"""# Generated by AMPG. Review before installing.
daemon = {protocol.daemon}
protocol = {protocol.name}
content_root = {site.outputs.root / protocol.name}
state_dir = {state_dir}
"""
def _managed_nginx_server_content(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(provider, site, protocol)
return f"""# Generated by AMPG. Review before installing.
worker_processes 1;
error_log {state_dir / "nginx-error.log"} notice;
pid {state_dir / "nginx.pid"};
events {{
worker_connections 128;
}}
http {{
access_log {state_dir / "nginx-access.log"};
server {{
listen 80;
listen [::]:80;
server_name {site.domain} www.{site.domain};
root {site.outputs.root / protocol.name};
index index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
}}
}}
"""
def _managed_nginx_loopback_content(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(provider, site, protocol)
port = int(protocol.options.get("local_port", 18080 if protocol.name == "tor" else 18081))
return f"""# Generated by AMPG. Review before installing.
worker_processes 1;
error_log {state_dir / "nginx-loopback-error.log"} notice;
pid {state_dir / "nginx-loopback.pid"};
events {{
worker_connections 128;
}}
http {{
access_log {state_dir / "nginx-loopback-access.log"};
server {{
listen 127.0.0.1:{port};
server_name _;
root {site.outputs.root / protocol.name};
index index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
}}
}}
"""
def _supervisor_filename(provider: PlatformProvider, service_suffix: str) -> str:
if provider.name == "linux-systemd":
return f"{service_suffix}.service"
if provider.name == "macos-launchd":
return f"org.ampg.{service_suffix}.plist"
return f"{service_suffix}.run"
def _supervisor_content(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
*,
service_suffix: str,
command: str,
) -> str:
service_name = _artifact_service_name(site, protocol, service_suffix)
if provider.name == "linux-systemd":
return f"""# Generated by AMPG. Review before installing.
[Unit]
Description=AMPG {protocol.name} {service_suffix} for {site.id}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory={_state_dir(provider, site, protocol)}
ExecStart=/bin/sh -lc '{_shell_single_quote(command)}'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
"""
if provider.name == "macos-launchd":
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.ampg.{service_name}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-lc</string>
<string>{_xml_escape(command)}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
"""
shell = "/data/data/com.termux/files/usr/bin/sh" if provider.name == "android-termux" else "/bin/sh"
return f"""#!{shell}
# Generated by AMPG. Review before installing.
set -eu
mkdir -p {_state_dir(provider, site, protocol)}
{command}
"""
def _daemon_start_command(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(provider, site, protocol)
if protocol.name == "tor":
return f"exec tor -f {state_dir / 'torrc'}"
if protocol.name == "i2p":
return f"exec i2pd --datadir={state_dir / 'data'} --tunconf={state_dir / 'i2pd-tunnels.conf'}"
if protocol.name == "gemini":
port = int(protocol.options.get("port", 1965))
return (
"exec agate "
f"--content {site.outputs.root / protocol.name} "
f"--addr [::]:{port} "
f"--hostname {site.domain} "
f"--certs {state_dir}"
)
if protocol.name == "clearnet":
return f"exec nginx -c {state_dir / 'nginx-server.conf'} -g 'daemon off;'"
return f"exec {protocol.daemon}"
def _loopback_start_command(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(provider, site, protocol)
return f"exec nginx -c {state_dir / 'nginx-loopback.conf'} -g 'daemon off;'"
def _artifact(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
*,
kind: str,
filename: str,
content: str,
) -> InstallArtifact:
return InstallArtifact(
site_id=site.id,
protocol=protocol.name,
platform=provider.name,
kind=kind,
path=_artifact_dir(site, protocol, provider) / filename,
content=content,
)
def _xml_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _shell_single_quote(value: str) -> str:
return value.replace("'", "'\\''")
def _step(
site: SiteConfig,
protocol: ProtocolConfig,
+4
View File
@@ -124,6 +124,10 @@ supervisor, and health-check steps. It does not install packages, create directo
start services. The output is a reviewable bridge between daemon ownership decisions and
future live apply support.
With `--write-artifacts`, install-plan writes managed config and supervisor files under
the configured `plan_root`; those files are review artifacts, not installed service
state.
Tor and I2P HTTP publishing include the transport daemon and the loopback web-serving
layer in the install plan, because the hidden service or tunnel must have a local HTTP
target.
+6
View File
@@ -40,6 +40,7 @@ config artifacts.
python3 -m ampg --config gateway.toml status
python3 -m ampg --config gateway.toml doctor
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p --write-artifacts
python3 -m ampg --config gateway.toml apply --dry-run
python3 -m ampg --config gateway.toml apply --dry-run --profile mobile-i2p
python3 -m ampg --config gateway.toml apply --dry-run --protocol i2p
@@ -59,6 +60,11 @@ health-check steps needed for AMPG-owned daemons. It is dry-run only. Adopted da
external-policy targets are shown as skipped, while unsupported platform/daemon pairs are
blocked.
`install-plan --write-artifacts` writes managed daemon configs and platform supervisor
files under the configured `plan_root`, grouped by site, protocol, and platform. For
Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopback nginx
artifacts.
`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
+140 -1
View File
@@ -6,7 +6,12 @@ from pathlib import Path
from ampg.cli import main
from ampg.config import load_config
from ampg.install_plan import blocked_install_steps, install_plan
from ampg.install_plan import (
blocked_install_steps,
install_artifacts,
install_plan,
write_install_artifacts,
)
from ampg.platforms import platform_by_name
from ampg.status import DaemonProbe
@@ -182,6 +187,140 @@ daemon_policy = "auto"
self.assertIn("blocked=0", output)
self.assertNotIn("protocol=clearnet", output)
def test_managed_i2p_artifacts_include_daemon_loopback_and_supervisors(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = load_config(
_write_config(
root,
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
daemon = "i2pd"
daemon_policy = "auto"
local_port = 19081
keys_file = "example-web.dat"
tunnel_name = "example-web"
""",
)
)
artifacts = install_artifacts(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
by_name = {artifact.path.name: artifact for artifact in artifacts}
self.assertEqual(
{
"i2pd-tunnels.conf",
"i2pd.run",
"nginx-loopback.conf",
"nginx-loopback.run",
},
set(by_name),
)
self.assertIn("[example-web]", by_name["i2pd-tunnels.conf"].content)
self.assertIn("keys =", by_name["i2pd-tunnels.conf"].content)
self.assertIn("listen 127.0.0.1:19081", by_name["nginx-loopback.conf"].content)
self.assertIn("exec i2pd", by_name["i2pd.run"].content)
self.assertIn("exec nginx", by_name["nginx-loopback.run"].content)
def test_write_install_artifacts_creates_review_files(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = load_config(
_write_config(
root,
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
daemon = "i2pd"
daemon_policy = "auto"
""",
)
)
artifacts = write_install_artifacts(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
for artifact in artifacts:
self.assertTrue(artifact.path.exists())
self.assertTrue(
(
root
/ "plan/example/i2p/managed/android-termux/i2pd-tunnels.conf"
).exists()
)
def test_install_plan_cli_can_write_managed_artifacts(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"
""",
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
status = main(
[
"--config",
str(config_path),
"install-plan",
"--profile",
"mobile-i2p",
"--write-artifacts",
]
)
output = stdout.getvalue()
self.assertEqual(0, status)
self.assertIn("AMPG_INSTALL_ARTIFACT", output)
self.assertIn("kind=daemon-config", output)
self.assertTrue(
(
root
/ "plan/example/i2p/managed/android-termux/i2pd-tunnels.conf"
).exists()
)
def test_systemd_supervisor_wraps_start_command_in_shell(self):
with tempfile.TemporaryDirectory() as tmp:
config = load_config(
_write_config(
Path(tmp),
"""
[site.protocols.tor]
enabled = true
renderer = "privacy-html"
daemon = "tor"
daemon_policy = "auto"
""",
)
)
artifacts = install_artifacts(
config,
platform_provider=platform_by_name("linux-systemd"),
daemon_probe=_probe(installed=False, running=False),
)
service = next(artifact for artifact in artifacts if artifact.path.name == "tor.service")
self.assertIn("ExecStart=/bin/sh -lc 'exec tor", service.content)
if __name__ == "__main__":
unittest.main()