diff --git a/README.md b/README.md index 490983e..e92b86c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ python3 -m ampg --config gateway.toml dns plan --free-domain-hints python3 -m ampg --config gateway.toml build python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml install-plan --profile vps-full --write-artifacts +python3 -m ampg --config gateway.toml deploy apply --stage packages --dry-run --profile vps-full python3 -m ampg --config gateway.toml approvals list --profile vps-full python3 -m ampg --config gateway.toml approvals approve --profile vps-full --all python3 -m ampg --config gateway.toml apply --dry-run --profile vps-full @@ -70,11 +71,12 @@ python3 -m ampg --config gateway.toml deploy apply --stage health --dry-run --pr transports left as one-line toggles. `deploy plan` condenses the lower-level checks into clear next steps. `dns plan` covers static DNS, Dynamic DNS, and behind-router reachability hints. `apply --dry-run` prints the activation sequence and a preflight -verdict without changing services. `deploy apply --stage state` copies approved -managed-daemon config into AMPG-owned state. `deploy apply --stage supervisor` installs -approved supervisor files after state exists. `deploy apply --stage start` starts -AMPG-owned services after state and supervisor files are present. `deploy apply --stage -addresses` captures daemon-written public addresses into the address registry. +verdict without changing services. `deploy apply --stage packages` installs selected +managed-daemon packages using structured platform commands. `deploy apply --stage state` +copies approved managed-daemon config into AMPG-owned state. `deploy apply --stage +supervisor` installs approved supervisor files after state exists. `deploy apply --stage +start` starts AMPG-owned services after state and supervisor files are present. `deploy +apply --stage addresses` captures daemon-written public addresses into the address registry. `deploy apply --stage health` verifies published fixture URLs through the selected transport. @@ -140,6 +142,8 @@ python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --protoco python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml approvals list --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml approvals approve --profile mobile-i2p --all +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage packages --dry-run --profile mobile-i2p +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage packages --profile mobile-i2p --yes python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage state --dry-run --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage state --profile mobile-i2p --yes python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p @@ -198,6 +202,11 @@ would be copied into managed state and the supervisor services that would be reg or started. The preflight gate reports `blocked`, `review`, or `ready` across activation steps, managed-state copies, and supervisor actions. +`deploy apply --stage packages --dry-run` shows managed-daemon packages that can be +installed automatically for the selected platform. The live form requires `--yes`, runs +only allowlisted package-manager commands, and leaves config, services, keys, and +addresses unchanged. Adopted and external daemons are skipped. + `deploy apply --stage state --dry-run` shows approved managed-daemon artifacts that are ready to copy into `gateway.state_dir`. The live form requires `--yes`; it creates only AMPG-owned state directories and copies approved artifact contents. It does not install @@ -247,6 +256,7 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] Run `ampg health-plan`; review post-start transport checks. - [ ] Run `ampg approvals approve --all` after reviewing generated artifacts. - [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate. +- [ ] Run `ampg deploy apply --stage packages --dry-run`; confirm package-manager commands. - [ ] Run `ampg deploy apply --stage state --dry-run`; confirm approved state copies. - [ ] Run `ampg deploy apply --stage supervisor --dry-run`; confirm approved service files. - [ ] Run `ampg deploy apply --stage start --dry-run`; confirm service-manager commands. diff --git a/ampg/apply.py b/ampg/apply.py index b92950d..c54b176 100644 --- a/ampg/apply.py +++ b/ampg/apply.py @@ -16,8 +16,10 @@ from .addresses import ( from .config import GatewayConfig from .health import HealthCheck, health_plan from .install_plan import ( + InstallPackageAction, InstallStateCopy, InstallSupervisorAction, + install_package_actions, install_state_copies, install_supervisor_actions, ) @@ -61,6 +63,19 @@ class CommandRunResult: stderr: str = "" +@dataclass(frozen=True) +class PackageApplyResult: + site_id: str + protocol: str + platform: str + package: str + status: str + mode: str + command: tuple[str, ...] + return_code: int | None + message: str + + @dataclass(frozen=True) class StartApplyResult: site_id: str @@ -106,6 +121,62 @@ class HealthApplyResult: CommandRunner = Callable[[tuple[str, ...]], CommandRunResult] +def apply_packages( + config: GatewayConfig, + *, + dry_run: bool, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, + command_runner: CommandRunner | None = None, +) -> list[PackageApplyResult]: + provider = platform_provider or detect_platform() + actions = install_package_actions( + config, + platform_provider=provider, + daemon_probe=daemon_probe, + ) + runner = command_runner or _run_command + results: list[PackageApplyResult] = [] + for action in actions: + validation = _validate_package_action(action, dry_run=dry_run) + if validation.status in {"blocked", "skipped"}: + results.append(validation) + continue + if dry_run: + results.append( + _package_result( + action, + status="planned", + mode="dry-run", + return_code=None, + message="would install managed daemon package", + ) + ) + continue + run = runner(action.command) + if run.return_code != 0: + results.append( + _package_result( + action, + status="blocked", + mode="live", + return_code=run.return_code, + message=_command_failure_message(run, label="package install command"), + ) + ) + continue + results.append( + _package_result( + action, + status="installed", + mode="live", + return_code=run.return_code, + message="installed managed daemon package", + ) + ) + return results + + def apply_state( config: GatewayConfig, *, @@ -268,7 +339,7 @@ def apply_start( status="blocked", mode="live", return_code=run.return_code, - message=_command_failure_message(run), + message=_command_failure_message(run, label="start command"), ) ) continue @@ -333,7 +404,7 @@ def apply_health( status="blocked", mode="live", return_code=run.return_code, - message=_command_failure_message(run), + message=_command_failure_message(run, label="health check command"), ) ) continue @@ -350,6 +421,61 @@ def apply_health( return results +def _validate_package_action( + action: InstallPackageAction, + *, + dry_run: bool, +) -> PackageApplyResult: + mode = "dry-run" if dry_run else "live" + if action.status == "ready": + return _package_result( + action, + status="skipped", + mode=mode, + return_code=None, + message=action.message, + ) + if action.status != "planned": + return _package_result( + action, + status="blocked", + mode=mode, + return_code=None, + message=action.message, + ) + if not _valid_package_name(action.package): + return _package_result( + action, + status="blocked", + mode=mode, + return_code=None, + message=f"invalid package name: {action.package}", + ) + if not action.command: + return _package_result( + action, + status="blocked", + mode=mode, + return_code=None, + message="selected platform does not have an automatic package install command", + ) + if not _allowed_package_command(action): + return _package_result( + action, + status="blocked", + mode=mode, + return_code=None, + message="package install command is not allowlisted", + ) + return _package_result( + action, + status="ready", + mode=mode, + return_code=None, + message="package install command passed safety checks", + ) + + def _validate_state_copy( config: GatewayConfig, copy: InstallStateCopy, @@ -614,6 +740,27 @@ def _address_result( ) +def _package_result( + action: InstallPackageAction, + *, + status: str, + mode: str, + return_code: int | None, + message: str, +) -> PackageApplyResult: + return PackageApplyResult( + site_id=action.site_id, + protocol=action.protocol, + platform=action.platform, + package=action.package, + status=status, + mode=mode, + command=action.command, + return_code=return_code, + message=message, + ) + + def _health_result( check: HealthCheck, *, @@ -758,6 +905,23 @@ def _valid_service_name(service: str) -> bool: return all(char.isalnum() or char in {"-", "_", "."} for char in service) +def _valid_package_name(package: str) -> bool: + if not package or package == "-": + return False + return all(char.isalnum() or char in {"-", "_", ".", "+"} for char in package) + + +def _allowed_package_command(action: InstallPackageAction) -> bool: + package = action.package + if action.platform == "android-termux": + return action.command == ("pkg", "install", package) + if action.platform == "linux-systemd": + return action.command == ("sudo", "apt", "install", package) + if action.platform == "macos-launchd": + return action.command == ("brew", "install", package) + return False + + def _chmod_supervisor_target(provider: PlatformProvider, target: Path) -> None: if provider.name not in {"android-termux", "linux-user"}: return @@ -790,11 +954,11 @@ def _run_command(command: tuple[str, ...]) -> CommandRunResult: ) -def _command_failure_message(run: CommandRunResult) -> str: +def _command_failure_message(run: CommandRunResult, *, label: str = "command") -> str: detail = run.stderr or run.stdout if detail: - return f"start command failed: {detail}" - return "start command failed" + return f"{label} failed: {detail}" + return f"{label} failed" def format_command(command: tuple[str, ...]) -> str: diff --git a/ampg/cli.py b/ampg/cli.py index 1c7e3e3..2a00c4b 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -24,11 +24,13 @@ from .addresses import ( from .apply import ( AddressApplyResult, HealthApplyResult, + PackageApplyResult, StateApplyResult, StartApplyResult, SupervisorApplyResult, apply_addresses, apply_health, + apply_packages, apply_state, apply_start, apply_supervisor, @@ -164,7 +166,7 @@ def main(argv: list[str] | None = None) -> int: _add_target_selection(deploy_apply_parser) deploy_apply_parser.add_argument( "--stage", - choices=("state", "supervisor", "start", "addresses", "health"), + choices=("packages", "state", "supervisor", "start", "addresses", "health"), required=True, help="Deployment stage to apply.", ) @@ -583,6 +585,29 @@ def _cmd_deploy(config, args) -> int: ) return 1 platform_provider = _platform_override(config, args) + if args.stage == "packages": + results = apply_packages( + config, + dry_run=args.dry_run, + platform_provider=platform_provider, + ) + for result in results: + _print_package_apply_result(result) + blocked = [result for result in results if result.status == "blocked"] + installed = [result for result in results if result.status == "installed"] + planned = [result for result in results if result.status == "planned"] + skipped = [result for result in results if result.status == "skipped"] + print( + "AMPG_DEPLOY_APPLY_SUMMARY " + f"stage=packages " + f"mode={'dry-run' if args.dry_run else 'live'} " + f"results={len(results)} " + f"planned={len(planned)} " + f"installed={len(installed)} " + f"skipped={len(skipped)} " + f"blocked={len(blocked)}" + ) + return 1 if blocked else 0 if args.stage == "state": results = apply_state( config, @@ -1171,6 +1196,22 @@ def _print_state_apply_result(result: StateApplyResult) -> None: ) +def _print_package_apply_result(result: PackageApplyResult) -> None: + return_code = "-" if result.return_code is None else str(result.return_code) + print( + "AMPG_DEPLOY_PACKAGE " + f"site={result.site_id} " + f"protocol={result.protocol} " + f"platform={result.platform} " + f"package=\"{_quote(result.package)}\" " + f"mode={result.mode} " + f"status={result.status} " + f"return_code={return_code} " + f"command=\"{_quote(format_command(result.command))}\" " + f"message=\"{_quote(result.message)}\"" + ) + + def _print_supervisor_apply_result(result: SupervisorApplyResult) -> None: print( "AMPG_DEPLOY_SUPERVISOR " diff --git a/ampg/deploy.py b/ampg/deploy.py index e434c95..7fdbb6e 100644 --- a/ampg/deploy.py +++ b/ampg/deploy.py @@ -19,6 +19,7 @@ from .dns import dns_plan from .health import health_plan from .install_plan import ( install_artifacts, + install_package_actions, install_plan, install_state_copies, install_supervisor_actions, @@ -82,6 +83,12 @@ def deploy_plan( platform_provider=platform_provider, daemon_probe=daemon_probe, ), + _package_step( + config, + command_context, + platform_provider=platform_provider, + daemon_probe=daemon_probe, + ), _artifact_step(config, command_context, platform_provider=platform_provider), _apply_preflight_step( config, @@ -230,6 +237,52 @@ def _daemon_step( ) +def _package_step( + config: GatewayConfig, + commands: _CommandContext, + *, + platform_provider: PlatformProvider | None, + daemon_probe: DaemonProbeFunc | None, +) -> DeployStep: + actions = install_package_actions( + config, + platform_provider=platform_provider, + daemon_probe=daemon_probe, + ) + if not actions: + return DeployStep("packages", "ready", "-", "no managed package installs are needed") + blocked = [action for action in actions if action.status == "blocked"] + planned = [action for action in actions if action.status == "planned"] + manual = [action for action in planned if not action.command] + if blocked: + return DeployStep( + "packages", + "blocked", + commands.command("install-plan", platform=True), + f"fix {len(blocked)} package planning blocker(s)", + ) + if manual: + return DeployStep( + "packages", + "review", + commands.command("install-plan", platform=True), + f"install {len(manual)} package(s) with the local package manager", + ) + if planned: + return DeployStep( + "packages", + "todo", + commands.command("deploy apply --stage packages --dry-run", platform=True), + f"install {len(planned)} managed daemon package(s)", + ) + return DeployStep( + "packages", + "ready", + commands.command("deploy apply --stage packages --dry-run", platform=True), + "managed daemon packages are installed", + ) + + def _artifact_step( config: GatewayConfig, commands: _CommandContext, diff --git a/ampg/install_plan.py b/ampg/install_plan.py index e044448..1b10ebe 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -66,6 +66,17 @@ class InstallSupervisorAction: message: str +@dataclass(frozen=True) +class InstallPackageAction: + site_id: str + protocol: str + platform: str + package: str + status: str + command: tuple[str, ...] + message: str + + def install_plan( config: GatewayConfig, *, @@ -120,6 +131,47 @@ def install_artifacts( return artifacts +def install_package_actions( + config: GatewayConfig, + *, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> list[InstallPackageAction]: + provider = platform_provider or detect_platform() + steps = install_plan( + config, + platform_provider=provider, + daemon_probe=daemon_probe, + ) + actions: list[InstallPackageAction] = [] + for step in steps: + if step.stage == "package": + actions.append( + InstallPackageAction( + site_id=step.site_id, + protocol=step.protocol, + platform=step.platform, + package=step.target, + status=step.status, + command=package_install_command(provider, step.target), + message=step.message, + ) + ) + elif step.status == "blocked": + actions.append( + InstallPackageAction( + site_id=step.site_id, + protocol=step.protocol, + platform=step.platform, + package=step.target, + status="blocked", + command=(), + message=step.message, + ) + ) + return actions + + def write_install_artifacts( config: GatewayConfig, *, @@ -513,13 +565,20 @@ def _package_name(provider: PlatformProvider, daemon: str) -> str: return daemon -def _package_command(provider: PlatformProvider, package: str) -> str: +def package_install_command(provider: PlatformProvider, package: str) -> tuple[str, ...]: if provider.name == "android-termux": - return f"pkg install {package}" + return ("pkg", "install", package) if provider.name == "linux-systemd": - return f"sudo apt install {package}" + return ("sudo", "apt", "install", package) if provider.name == "macos-launchd": - return f"brew install {package}" + return ("brew", "install", package) + return () + + +def _package_command(provider: PlatformProvider, package: str) -> str: + command = package_install_command(provider, package) + if command: + return shlex.join(command) if provider.name == "linux-user": return f"install {package} with the user package manager" return "-" diff --git a/docs/architecture.md b/docs/architecture.md index 62cb019..699f524 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,6 +131,10 @@ managed state and planned supervisor start/reload actions. The command emits an Blocked or unapproved preflight items stop mutation before any live apply path can touch services. +`ampg deploy apply --stage packages` is the narrow live stage for package installation. +It runs only structured package-manager commands for selected managed daemons. Adopted +or external daemons do not require package apply. + `ampg deploy apply --stage state` is the narrow live stage for managed state. It reads the same approved artifact plan, refuses missing, stale, or unapproved inputs, and copies only approved generated daemon config into AMPG-owned state. @@ -159,7 +163,7 @@ root. `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. +the staged live apply commands. With `--write-artifacts`, install-plan writes managed config and supervisor files under the configured `plan_root`; those files are review artifacts, not installed service diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 103289d..c2080a1 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -56,6 +56,8 @@ python3 -m ampg --config gateway.toml apply --dry-run --protocol i2p python3 -m ampg --config gateway.toml apply --dry-run --write-artifacts python3 -m ampg --config gateway.toml approvals list --profile mobile-i2p python3 -m ampg --config gateway.toml approvals approve --profile mobile-i2p --all +python3 -m ampg --config gateway.toml deploy apply --stage packages --dry-run --profile mobile-i2p +python3 -m ampg --config gateway.toml deploy apply --stage packages --profile mobile-i2p --yes python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --profile mobile-i2p python3 -m ampg --config gateway.toml deploy apply --stage state --profile mobile-i2p --yes python3 -m ampg --config gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p @@ -70,8 +72,8 @@ python3 -m ampg --config gateway.toml doctor --platform android-termux ``` `deploy plan` summarizes the current deployment in plain stages: source, build, DNS, -doctor, daemons, artifacts, addresses, and apply preflight. It prints the next commands -to run and does not change files or services. +doctor, daemons, packages, artifacts, addresses, and apply preflight. It prints the next +commands to run and does not change files or services. `dns plan` prints clearnet DNS records and reachability hints. Static mode plans A/AAAA and `www` records. Dynamic mode supports a Dynamic DNS hostname and warns when apex @@ -99,6 +101,10 @@ Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopbac artifacts. The generated daemon configs store runtime state, identity keys, logs, and daemon-written addresses under `gateway.state_dir`. +`deploy apply --stage packages` installs selected managed-daemon packages with +structured, allowlisted platform commands. Adopted and external daemons are skipped. +Platforms without an automatic package backend remain manual review items. + `state-contract` prints the resolved managed-state paths for enabled protocols, including which paths are AMPG-owned, daemon-written, required, or sensitive. @@ -127,30 +133,36 @@ reviewable config snippets to the configured plan root, then prints the copies f approved artifacts into `gateway.state_dir` and the supervisor services that would be registered or started. It still does not install or reload services. -`deploy apply --stage state` is the first live apply stage. Dry-run mode prints +`deploy apply --stage packages` is the first live apply stage. Dry-run mode prints +`AMPG_DEPLOY_PACKAGE` rows. Live mode requires `--yes`, runs only structured +package-manager commands for selected managed daemons, and reports installed, skipped, or +blocked results. It does not write config, register services, start daemons, capture +addresses, or run health checks. + +`deploy apply --stage state` is the second live apply stage. Dry-run mode prints `AMPG_DEPLOY_STATE` rows. Live mode requires `--yes`, refuses unapproved or stale artifacts, creates AMPG-owned state directories, and copies approved managed-daemon config into `gateway.state_dir`. It does not install packages, edit adopted daemon configs, start services, or delete identity material. -`deploy apply --stage supervisor` is the second live apply stage. Dry-run mode prints +`deploy apply --stage supervisor` is the third live apply stage. Dry-run mode prints `AMPG_DEPLOY_SUPERVISOR` rows. Live mode requires `--yes`, refuses unapproved or stale artifacts, requires the state stage to have copied managed config first, and writes AMPG-named service files for the selected platform. It does not install packages, start services, reload service managers, or delete service files. -`deploy apply --stage start` is the third live apply stage. Dry-run mode prints +`deploy apply --stage start` is the fourth live apply stage. Dry-run mode prints `AMPG_DEPLOY_START` rows with exact service-manager commands. Live mode requires `--yes`, requires both state and supervisor files to exist, and runs only structured platform commands for AMPG-named services. It does not install packages, rewrite config, remove files, capture addresses, or run health checks. -`deploy apply --stage addresses` is the fourth live apply stage. Dry-run mode prints +`deploy apply --stage addresses` is the fifth live apply stage. Dry-run mode prints `AMPG_DEPLOY_ADDRESS` rows for configured, skipped, missing, and capturable addresses. Live mode requires `--yes` and writes captured transport addresses under `gateway.state_dir`. It does not rewrite daemon config or run health checks. -`deploy apply --stage health` is the fifth live apply stage. Dry-run mode prints +`deploy apply --stage health` is the sixth live apply stage. Dry-run mode prints `AMPG_DEPLOY_HEALTH` rows with published fixture URLs and transport commands. Live mode requires `--yes`, runs those commands, and reports pass/fail. It does not change daemon config, service state, or address registries. @@ -171,9 +183,11 @@ 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. +Package commands in `install-plan` are review hints. `deploy apply --stage packages` is +the executable package stage for supported automatic backends: Systemd hosts use +`sudo apt install`, Termux uses `pkg install`, and macOS uses `brew install`. User-space +Linux asks the operator to use the local package manager until AMPG has a concrete +backend for that environment. ## Adapter notes diff --git a/docs/state-contract.md b/docs/state-contract.md index 776d102..2de3f8c 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -1,6 +1,6 @@ # State Contract -> Status: draft | Updated 2026-07-06 | Applies to: managed daemon state +> Status: draft | Updated 2026-07-07 | Applies to: managed daemon state AMPG keeps managed daemon state under `gateway.state_dir`. The state contract describes which files AMPG owns, which files daemons or adapters write, which paths may contain @@ -48,6 +48,10 @@ for the services that would be registered or started. The accompanying `AMPG_APPLY_PREFLIGHT` verdict includes those rows so live apply can refuse mutation when state or supervisor inputs still need review. +`deploy apply --stage packages` performs package installation only. It uses structured +platform package-manager commands for selected managed daemons and does not write AMPG +state, service files, generated config, or address registries. + `deploy apply --stage state` performs the approved copy portion only. It requires `--yes` for live writes, refuses unapproved or stale artifacts, writes only under `gateway.state_dir`, and leaves supervisor file installation, daemon start, address diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index e3ddedd..79d394b 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -141,9 +141,9 @@ daemon_policy = "auto" - `ampg build` writes a fixture manifest that AMPB can check. - `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live transport daemons are installed or adopted. -- `ampg deploy apply --stage state`, `--stage supervisor`, `--stage start`, - `--stage addresses`, and `--stage health` stage Wownero transport publication without - mixing concerns. +- `ampg deploy apply --stage packages`, `--stage state`, `--stage supervisor`, + `--stage start`, `--stage addresses`, and `--stage health` stage Wownero transport + publication without mixing concerns. - Tor output contains no `