diff --git a/README.md b/README.md index 8a789cf..5a6b6e4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ 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 install-plan --profile mobile-i2p 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 @@ -145,6 +146,7 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] Select protocols and daemon policy. - [ ] Run `ampg plan`; review output roots, ports, and config changes. - [ ] Run `ampg status` or `ampg doctor`; review daemon ownership decisions. +- [ ] Run `ampg install-plan`; review managed daemon package, state, and supervisor steps. - [ ] Run `ampg build`; inspect generated variants. - [ ] Run AMPB fixture checks against generated manifests. - [ ] Run `ampg apply --dry-run`; review the activation sequence. diff --git a/ampg/cli.py b/ampg/cli.py index 19589f9..73a32d7 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -14,6 +14,7 @@ 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 .manifest import write_fixture_manifests from .plan import plan_gateway, write_plan_artifacts from .platforms import PLATFORM_NAMES, platform_by_name @@ -84,6 +85,16 @@ def main(argv: list[str] | None = None) -> int: choices=PLATFORM_NAMES, help="Override platform detection for dry-run checks.", ) + install_plan_parser = subcommands.add_parser( + "install-plan", + help="Print managed daemon package, state, and supervisor steps.", + ) + _add_target_selection(install_plan_parser) + install_plan_parser.add_argument( + "--platform", + choices=PLATFORM_NAMES, + help="Override platform detection for install planning.", + ) 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.") @@ -172,6 +183,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_doctor(config, args) if args.command == "apply": return _cmd_apply(config, args) + if args.command == "install-plan": + return _cmd_install_plan(config, args) if args.command == "build": return _cmd_build(config) if args.command == "manifest": @@ -218,6 +231,23 @@ def _cmd_apply(config, args) -> int: return 1 if blocked else 0 +def _cmd_install_plan(config, args) -> int: + steps = install_plan(config, platform_provider=_platform_override(config, args)) + for step in steps: + _print_install_step(step) + blocked = blocked_install_steps(steps) + print( + "AMPG_INSTALL_SUMMARY " + f"sites={len(config.sites)} " + f"steps={len(steps)} " + f"planned={sum(1 for step in steps if step.status == 'planned')} " + f"ready={sum(1 for step in steps if step.status == 'ready')} " + f"skipped={sum(1 for step in steps if step.status == 'skipped')} " + f"blocked={len(blocked)}" + ) + return 1 if blocked else 0 + + def _add_target_selection(parser) -> None: parser.add_argument( "--profile", @@ -334,6 +364,21 @@ def _cmd_build(config) -> int: return 0 +def _print_install_step(step: InstallStep) -> None: + print( + "AMPG_INSTALL_STEP " + f"site={step.site_id} " + f"protocol={step.protocol} " + f"platform={step.platform} " + f"stage={step.stage} " + f"action={step.action} " + f"target=\"{_quote(step.target)}\" " + f"status={step.status} " + f"command=\"{_quote(step.command)}\" " + f"message=\"{_quote(step.message)}\"" + ) + + def _print_activation_step(step: ActivationStep) -> None: print( "AMPG_APPLY_STEP " diff --git a/ampg/install_plan.py b/ampg/install_plan.py new file mode 100644 index 0000000..e2511bf --- /dev/null +++ b/ampg/install_plan.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .config import GatewayConfig, ProtocolConfig, SiteConfig +from .platforms import PlatformProvider, detect_platform +from .status import DaemonProbeFunc, TransportStatus, gateway_status +from .transports import fallback_transport_adapter, transport_adapter + + +@dataclass(frozen=True) +class InstallStep: + site_id: str + protocol: str + platform: str + stage: str + action: str + target: str + status: str + command: str + message: str + + +def install_plan( + config: GatewayConfig, + *, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> list[InstallStep]: + 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 + } + steps: list[InstallStep] = [] + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + status = status_by_protocol[(site.id, protocol.name)] + steps.extend(_protocol_install_steps(site, protocol, provider, status)) + return steps + + +def blocked_install_steps(steps: list[InstallStep]) -> list[InstallStep]: + return [step for step in steps if step.status == "blocked"] + + +def _protocol_install_steps( + site: SiteConfig, + protocol: ProtocolConfig, + provider: PlatformProvider, + status: TransportStatus, +) -> list[InstallStep]: + if status.action == "adopt-existing": + return [ + _step( + site, + protocol, + provider, + stage="daemon", + action="adopt-existing", + target=protocol.daemon, + status="skipped", + command="-", + message="existing daemon is running; no AMPG-owned install is planned", + ) + ] + if status.action == "render-only": + return [ + _step( + site, + protocol, + provider, + stage="daemon", + action="render-only", + target=protocol.daemon, + status="skipped", + command="-", + message="external policy renders outputs and review artifacts only", + ) + ] + + if status.action != "manage-owned" or status.status == "error": + return [ + _step( + site, + protocol, + provider, + stage="daemon", + action=status.action, + target=protocol.daemon, + status="blocked", + command="-", + message=status.message, + ) + ] + + adapter = transport_adapter(protocol.name, protocol.daemon) + adapter = adapter or fallback_transport_adapter(protocol.name, protocol.daemon) + package_steps = [ + _package_step(site, protocol, provider, protocol.daemon, installed=status.installed) + ] + package_steps.extend( + _package_step(site, protocol, provider, support_daemon, installed=False) + for support_daemon in _support_daemons(protocol) + ) + return [ + *package_steps, + _step( + site, + protocol, + provider, + stage="state", + action="create-state", + target=str(_state_dir(provider, site, protocol)), + status="planned", + command=_mkdir_command(provider, _state_dir(provider, site, protocol)), + message="create AMPG-owned state directory for daemon config, keys, and logs", + ), + _step( + site, + protocol, + provider, + stage="config", + action="write-managed-config", + target=str(_state_dir(provider, site, protocol) / "config"), + status="planned", + command="-", + message=f"write managed {adapter.backend} config after plan review", + ), + _step( + site, + protocol, + provider, + stage="supervisor", + action="configure-supervisor", + target=_service_name(site, protocol), + status="planned", + command=_supervisor_command(provider, site, protocol), + message=f"register AMPG-owned service with {provider.process_supervisor}", + ), + _step( + site, + protocol, + provider, + stage="health", + action="check-transport", + target=protocol.name, + status="planned", + command="-", + message="verify generated site through the managed transport after start", + ), + ] + + +def _package_step( + site: SiteConfig, + protocol: ProtocolConfig, + provider: PlatformProvider, + daemon: str, + *, + installed: bool, +) -> InstallStep: + package = _package_name(provider, daemon) + return _step( + site, + protocol, + provider, + stage="package", + action="ensure-package", + target=package, + status="ready" if installed else "planned", + command="-" if installed else _package_command(provider, package), + message=( + f"{daemon} executable is already present" + if installed + else f"install package providing {daemon}" + ), + ) + + +def _support_daemons(protocol: ProtocolConfig) -> tuple[str, ...]: + if protocol.name in {"tor", "i2p"}: + return ("nginx",) + return () + + +def _package_name(provider: PlatformProvider, daemon: str) -> str: + if provider.name == "macos-launchd" and daemon == "ipfs": + return "kubo" + return daemon + + +def _package_command(provider: PlatformProvider, package: str) -> str: + if provider.name == "android-termux": + return f"pkg install {package}" + if provider.name == "linux-systemd": + return f"sudo apt install {package}" + if provider.name == "macos-launchd": + return f"brew install {package}" + if provider.name == "linux-user": + return f"install {package} with the user package manager" + return "-" + + +def _mkdir_command(provider: PlatformProvider, path: Path) -> str: + prefix = "sudo " if provider.can_write_system_config else "" + return f"{prefix}mkdir -p {path}" + + +def _supervisor_command( + provider: PlatformProvider, + site: SiteConfig, + protocol: ProtocolConfig, +) -> str: + service = _service_name(site, protocol) + if provider.name == "android-termux": + return f"sv-enable {service}" + if provider.name == "linux-systemd": + return f"sudo systemctl enable --now {service}.service" + if provider.name == "macos-launchd": + return f"launchctl bootstrap gui/$UID ~/Library/LaunchAgents/org.ampg.{service}.plist" + if provider.name == "linux-user": + return f"run {service} under the user supervisor" + return "-" + + +def _state_dir( + provider: PlatformProvider, + site: SiteConfig, + protocol: ProtocolConfig, +) -> Path: + return provider.state_root / site.id / protocol.name + + +def _service_name(site: SiteConfig, protocol: ProtocolConfig) -> str: + return f"ampg-{site.id}-{protocol.name}" + + +def _step( + site: SiteConfig, + protocol: ProtocolConfig, + provider: PlatformProvider, + *, + stage: str, + action: str, + target: str, + status: str, + command: str, + message: str, +) -> InstallStep: + return InstallStep( + site_id=site.id, + protocol=protocol.name, + platform=provider.name, + stage=stage, + action=action, + target=target, + status=status, + command=command, + message=message, + ) diff --git a/docs/architecture.md b/docs/architecture.md index 70417af..274129b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,6 +117,17 @@ The dry-run command is intentionally stricter than `doctor`: missing generated o blocked during activation, because AMPG must not point a transport at an unbuilt output root. +## Install plan + +`ampg install-plan` translates managed daemon decisions into package, state, config, +supervisor, and health-check steps. It does not install packages, create directories, or +start services. The output is a reviewable bridge between daemon ownership decisions and +future live apply support. + +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. + ## Interaction boundary v1 is static-first, but the architecture reserves a path for interactive applications. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 782dc71..ef1f0ca 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -39,6 +39,7 @@ config artifacts. ```sh 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 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 @@ -53,6 +54,11 @@ 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. +`install-plan` prints the package, state-directory, managed-config, supervisor, and +health-check steps needed for AMPG-owned daemons. It is dry-run only. Adopted daemons and +external-policy targets are shown as skipped, while unsupported platform/daemon pairs are +blocked. + `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 @@ -75,6 +81,10 @@ Platform providers describe how AMPG may supervise managed daemons: - `android-termux`: Termux-style user-space daemons for mobile/server experiments. - `unknown`: render and plan only; daemon management is disabled. +Package commands in `install-plan` are review hints, not executed actions. Systemd hosts +currently emit `apt`-style package hints, Termux emits `pkg`, macOS emits `brew`, and +user-space Linux asks the operator to use the local user package manager. + ## Adapter notes ### Clearnet diff --git a/tests/test_install_plan.py b/tests/test_install_plan.py new file mode 100644 index 0000000..b63a80a --- /dev/null +++ b/tests/test_install_plan.py @@ -0,0 +1,187 @@ +import contextlib +import io +import tempfile +import unittest +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.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("