From 1641f2009d41cd2f090025fbf39b480a728645a8 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 05:50:12 -0400 Subject: [PATCH] Write managed install artifacts --- README.md | 5 + ampg/cli.py | 28 ++- ampg/install_plan.py | 382 +++++++++++++++++++++++++++++++++++++ docs/architecture.md | 4 + docs/daemon-management.md | 6 + tests/test_install_plan.py | 141 +++++++++++++- 6 files changed, 563 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5a6b6e4..3486277 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ampg/cli.py b/ampg/cli.py index 73a32d7..a93c768 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -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) diff --git a/ampg/install_plan.py b/ampg/install_plan.py index e2511bf..be5dc4d 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -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""" + + + + Label + org.ampg.{service_name} + ProgramArguments + + /bin/sh + -lc + {_xml_escape(command)} + + RunAtLoad + + KeepAlive + + + +""" + 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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def _shell_single_quote(value: str) -> str: + return value.replace("'", "'\\''") + + def _step( site: SiteConfig, protocol: ProtocolConfig, diff --git a/docs/architecture.md b/docs/architecture.md index 274129b..5f812bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index ef1f0ca..6b78832 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -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 diff --git a/tests/test_install_plan.py b/tests/test_install_plan.py index b63a80a..4c47b2d 100644 --- a/tests/test_install_plan.py +++ b/tests/test_install_plan.py @@ -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()