From c3d44a5e5b01cecd72a6a24ee4e64377e6a556cf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 7 Jul 2026 05:29:34 -0400 Subject: [PATCH] Add start stage deploy apply --- README.md | 14 +- ampg/apply.py | 271 ++++++++++++++++++++++++++++++++++++ ampg/cli.py | 44 +++++- ampg/deploy.py | 14 +- docs/architecture.md | 7 +- docs/daemon-management.md | 8 ++ docs/state-contract.md | 8 +- docs/wownero-integration.md | 7 +- tests/test_deploy_apply.py | 143 ++++++++++++++++++- 9 files changed, 504 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a6d1090..275b80f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ python3 -m ampg --config gateway.toml approvals approve --profile vps-full --all python3 -m ampg --config gateway.toml apply --dry-run --profile vps-full python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --profile vps-full python3 -m ampg --config gateway.toml deploy apply --stage supervisor --dry-run --profile vps-full +python3 -m ampg --config gateway.toml deploy apply --stage start --dry-run --profile vps-full ``` `init site` writes a readable `gateway.toml` with selected transports enabled and common @@ -69,8 +70,8 @@ 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. Service start remains a separate future -stage. +approved supervisor files after state exists. `deploy apply --stage start` starts +AMPG-owned services after state and supervisor files are present. ## Interaction tiers @@ -138,6 +139,8 @@ python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage stat 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 python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage supervisor --profile mobile-i2p --yes +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage start --dry-run --profile mobile-i2p +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage start --profile mobile-i2p --yes python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol i2p @@ -195,6 +198,12 @@ installed after managed state config exists. The live form requires `--yes`; it only AMPG-named service files for the selected platform. It does not install packages, invoke `systemctl`, `launchctl`, or `sv-enable`, start daemons, or remove service files. +`deploy apply --stage start --dry-run` shows the service-manager commands for +AMPG-owned services whose state and supervisor files are already installed. The live form +requires `--yes`; it runs only structured platform commands for AMPG-named services. It +does not install packages, rewrite config, delete files, capture addresses, or run health +checks. + `dns plan --free-domain-hints` prints optional community subdomain services that may help new users get a clearnet name without buying a domain. These are third-party registries; operators still need to review current terms, availability, content rules, and DNS @@ -222,4 +231,5 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate. - [ ] 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. - [ ] Run live apply after the preflight gate is ready. diff --git a/ampg/apply.py b/ampg/apply.py index e16168c..4517b88 100644 --- a/ampg/apply.py +++ b/ampg/apply.py @@ -1,8 +1,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +import os from pathlib import Path import shutil +import shlex +import subprocess from .config import GatewayConfig from .install_plan import ( @@ -44,6 +48,31 @@ class SupervisorApplyResult: message: str +@dataclass(frozen=True) +class CommandRunResult: + return_code: int + stdout: str = "" + stderr: str = "" + + +@dataclass(frozen=True) +class StartApplyResult: + site_id: str + protocol: str + platform: str + kind: str + service: str + target: Path + status: str + mode: str + command: tuple[str, ...] + return_code: int | None + message: str + + +CommandRunner = Callable[[tuple[str, ...]], CommandRunResult] + + def apply_state( config: GatewayConfig, *, @@ -145,6 +174,85 @@ def apply_supervisor( return results +def apply_start( + config: GatewayConfig, + *, + dry_run: bool, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, + command_runner: CommandRunner | None = None, +) -> list[StartApplyResult]: + provider = platform_provider or detect_platform() + actions = install_supervisor_actions( + config, + platform_provider=provider, + daemon_probe=daemon_probe, + ) + state_copies = install_state_copies( + config, + platform_provider=provider, + daemon_probe=daemon_probe, + ) + state_targets = { + (copy.site_id, copy.protocol, copy.platform, copy.kind): copy.target + for copy in state_copies + } + runner = command_runner or _run_command + results: list[StartApplyResult] = [] + for action in actions: + target = supervisor_target(provider, action) + command = start_command(provider, action, target) + validation = _validate_start_action( + action, + target=target, + command=command, + state_targets=state_targets, + dry_run=dry_run, + ) + if validation.status == "blocked": + results.append(validation) + continue + if dry_run: + results.append( + _start_result( + action, + target=target, + command=command, + status="planned", + mode="dry-run", + return_code=None, + message="would start AMPG-owned service", + ) + ) + continue + run = runner(command) + if run.return_code != 0: + results.append( + _start_result( + action, + target=target, + command=command, + status="blocked", + mode="live", + return_code=run.return_code, + message=_command_failure_message(run), + ) + ) + continue + results.append( + _start_result( + action, + target=target, + command=command, + status="started", + mode="live", + return_code=run.return_code, + message="started AMPG-owned service", + ) + ) + return results + + def _validate_state_copy( config: GatewayConfig, copy: InstallStateCopy, @@ -259,6 +367,91 @@ def _validate_supervisor_action( ) +def _validate_start_action( + action: InstallSupervisorAction, + *, + target: Path, + command: tuple[str, ...], + state_targets: dict[tuple[str, str, str, str], Path], + dry_run: bool, +) -> StartApplyResult: + mode = "dry-run" if dry_run else "live" + if action.status != "planned": + return _start_result( + action, + target=target, + command=command, + status="blocked", + mode=mode, + return_code=None, + message=action.message, + ) + if not _valid_service_name(action.service): + return _start_result( + action, + target=target, + command=command, + status="blocked", + mode=mode, + return_code=None, + message=f"invalid AMPG supervisor service name: {action.service}", + ) + required_state_kind = _required_state_kind(action.kind) + if required_state_kind is not None: + state_target = state_targets.get( + (action.site_id, action.protocol, action.platform, required_state_kind) + ) + if state_target is None or not state_target.exists(): + return _start_result( + action, + target=target, + command=command, + status="blocked", + mode=mode, + return_code=None, + message="managed state config is missing; run deploy apply --stage state first", + ) + if not target.exists(): + return _start_result( + action, + target=target, + command=command, + status="blocked", + mode=mode, + return_code=None, + message="supervisor file is missing; run deploy apply --stage supervisor first", + ) + if target.is_dir(): + return _start_result( + action, + target=target, + command=command, + status="blocked", + mode=mode, + return_code=None, + message="supervisor target is a directory", + ) + if not command: + return _start_result( + action, + target=target, + command=command, + status="blocked", + mode=mode, + return_code=None, + message="selected platform does not have an automatic start command", + ) + return _start_result( + action, + target=target, + command=command, + status="ready", + mode=mode, + return_code=None, + message="start command passed safety checks", + ) + + def _result( copy: InstallStateCopy, *, @@ -302,6 +495,31 @@ def _supervisor_result( ) +def _start_result( + action: InstallSupervisorAction, + *, + target: Path, + command: tuple[str, ...], + status: str, + mode: str, + return_code: int | None, + message: str, +) -> StartApplyResult: + return StartApplyResult( + site_id=action.site_id, + protocol=action.protocol, + platform=action.platform, + kind=action.kind, + service=action.service, + target=target, + status=status, + mode=mode, + command=command, + return_code=return_code, + message=message, + ) + + def supervisor_target(provider: PlatformProvider, action: InstallSupervisorAction) -> Path: if provider.name == "linux-systemd": return Path("/etc/systemd/system") / f"{action.service}.service" @@ -313,6 +531,20 @@ def supervisor_target(provider: PlatformProvider, action: InstallSupervisorActio return provider.state_root / "services" / action.service / action.source.name +def start_command( + provider: PlatformProvider, + action: InstallSupervisorAction, + target: Path, +) -> tuple[str, ...]: + if provider.name == "android-termux": + return ("sv-enable", action.service) + if provider.name == "linux-systemd": + return ("sudo", "systemctl", "enable", "--now", f"{action.service}.service") + if provider.name == "macos-launchd": + return ("launchctl", "bootstrap", f"gui/{os.getuid()}", str(target)) + return () + + def _termux_prefix(state_root: Path) -> Path: if len(state_root.parts) >= 3 and state_root.parts[-3:] == ("var", "lib", "ampg"): return state_root.parents[2] @@ -339,6 +571,45 @@ def _chmod_supervisor_target(provider: PlatformProvider, target: Path) -> None: target.chmod(target.stat().st_mode | 0o111) +def _run_command(command: tuple[str, ...]) -> CommandRunResult: + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired as exc: + return CommandRunResult( + return_code=124, + stdout=(exc.stdout or "").strip() if isinstance(exc.stdout, str) else "", + stderr=(exc.stderr or "start command timed out").strip() + if isinstance(exc.stderr, str) + else "start command timed out", + ) + except OSError as exc: + return CommandRunResult(return_code=127, stderr=str(exc)) + return CommandRunResult( + return_code=completed.returncode, + stdout=completed.stdout.strip(), + stderr=completed.stderr.strip(), + ) + + +def _command_failure_message(run: CommandRunResult) -> str: + detail = run.stderr or run.stdout + if detail: + return f"start command failed: {detail}" + return "start command failed" + + +def format_command(command: tuple[str, ...]) -> str: + if not command: + return "-" + return shlex.join(command) + + def _is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) diff --git a/ampg/cli.py b/ampg/cli.py index a46afec..5ef7791 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -23,9 +23,12 @@ from .addresses import ( ) from .apply import ( StateApplyResult, + StartApplyResult, SupervisorApplyResult, apply_state, + apply_start, apply_supervisor, + format_command, ) from .approvals import ( ACTIVATION_ARTIFACT_KIND, @@ -157,7 +160,7 @@ def main(argv: list[str] | None = None) -> int: _add_target_selection(deploy_apply_parser) deploy_apply_parser.add_argument( "--stage", - choices=("state", "supervisor"), + choices=("state", "supervisor", "start"), required=True, help="Deployment stage to apply.", ) @@ -618,6 +621,27 @@ def _cmd_deploy(config, args) -> int: f"blocked={len(blocked)}" ) return 1 if blocked else 0 + if args.stage == "start": + results = apply_start( + config, + dry_run=args.dry_run, + platform_provider=platform_provider, + ) + for result in results: + _print_start_apply_result(result) + blocked = [result for result in results if result.status == "blocked"] + started = [result for result in results if result.status == "started"] + planned = [result for result in results if result.status == "planned"] + print( + "AMPG_DEPLOY_APPLY_SUMMARY " + f"stage=start " + f"mode={'dry-run' if args.dry_run else 'live'} " + f"results={len(results)} " + f"planned={len(planned)} " + f"started={len(started)} " + f"blocked={len(blocked)}" + ) + return 1 if blocked else 0 return 1 @@ -1123,6 +1147,24 @@ def _print_supervisor_apply_result(result: SupervisorApplyResult) -> None: ) +def _print_start_apply_result(result: StartApplyResult) -> None: + return_code = "-" if result.return_code is None else str(result.return_code) + print( + "AMPG_DEPLOY_START " + f"site={result.site_id} " + f"protocol={result.protocol} " + f"platform={result.platform} " + f"kind={result.kind} " + f"service={result.service} " + f"mode={result.mode} " + f"target=\"{_quote(str(result.target))}\" " + f"status={result.status} " + f"return_code={return_code} " + f"command=\"{_quote(format_command(result.command))}\" " + f"message=\"{_quote(result.message)}\"" + ) + + def _print_dns_record(record: DNSRecordPlan) -> None: print( "AMPG_DNS_RECORD " diff --git a/ampg/deploy.py b/ampg/deploy.py index b720869..0ef1df5 100644 --- a/ampg/deploy.py +++ b/ampg/deploy.py @@ -5,7 +5,7 @@ from pathlib import Path import shlex from .activation import activation_steps, apply_preflight -from .apply import supervisor_target +from .apply import start_command, supervisor_target from .addresses import effective_address_records from .approvals import ( ACTIVATION_ARTIFACT_KIND, @@ -366,6 +366,18 @@ def _deploy_apply_step( f"install {len(missing_supervisors)} approved supervisor file(s)", ) if state_copies or supervisor_actions: + runnable = [ + action + for action in supervisor_actions + if start_command(provider, action, supervisor_target(provider, action)) + ] + if runnable: + return DeployStep( + "deploy-apply", + "todo", + commands.command("deploy apply --stage start --dry-run", platform=True), + f"start {len(runnable)} AMPG-owned service(s)", + ) return DeployStep( "deploy-apply", "ready", diff --git a/docs/architecture.md b/docs/architecture.md index 3dada77..205da36 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,8 +136,11 @@ the same approved artifact plan, refuses missing, stale, or unapproved inputs, a only approved generated daemon config into AMPG-owned state. `ampg deploy apply --stage supervisor` installs approved AMPG-named supervisor files -after the state files they reference exist. It does not start or reload services; daemon -start, address capture, and health verification remain separate stages. +after the state files they reference exist. It does not start or reload services. + +`ampg deploy apply --stage start` runs structured platform start commands for AMPG-named +services after state and supervisor files exist. Address capture and health verification +remain separate stages. 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 diff --git a/docs/daemon-management.md b/docs/daemon-management.md index a54967b..f27bead 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -60,6 +60,8 @@ python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --pro 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 python3 -m ampg --config gateway.toml deploy apply --stage supervisor --profile mobile-i2p --yes +python3 -m ampg --config gateway.toml deploy apply --stage start --dry-run --profile mobile-i2p +python3 -m ampg --config gateway.toml deploy apply --stage start --profile mobile-i2p --yes python3 -m ampg --config gateway.toml doctor --platform android-termux ``` @@ -131,6 +133,12 @@ artifacts, requires the state stage to have copied managed config first, and wri 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 +`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. + `--protocol` scopes operational commands to selected enabled protocols. A clearnet `adopt` failure will block a full activation run, but it will not block `apply --dry-run --protocol i2p` because clearnet is outside that selected run. diff --git a/docs/state-contract.md b/docs/state-contract.md index 4c6f89e..6ae50d9 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -55,5 +55,9 @@ capture, and published health checks for later stages. `deploy apply --stage supervisor` performs the approved supervisor-file installation portion only. It requires copied managed state config first, writes AMPG-named service -files for the selected platform, and leaves service-manager reload/start commands for a -later explicit stage. +files for the selected platform, and leaves service-manager start commands for the next +explicit stage. + +`deploy apply --stage start` runs the service-manager start commands for AMPG-named +services only after required state and supervisor files exist. It does not write or +delete state files. diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index 8618d84..d593177 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -141,8 +141,8 @@ 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 --dry-run` and `--stage supervisor --dry-run` show - approved managed-daemon files before any Wownero transport service is started. +- `ampg deploy apply --stage state`, `--stage supervisor`, and `--stage start` stage + Wownero transport services without mixing config writes and service start. - Tor output contains no `