Add start stage deploy apply

This commit is contained in:
jwinterm
2026-07-07 05:29:34 -04:00
parent f276eb470b
commit c3d44a5e5b
9 changed files with 504 additions and 12 deletions
+12 -2
View File
@@ -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.
+271
View File
@@ -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)
+43 -1
View File
@@ -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 "
+13 -1
View File
@@ -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",
+5 -2
View File
@@ -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
+8
View File
@@ -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.
+6 -2
View File
@@ -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.
+4 -3
View File
@@ -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 `<script>` tags or inline event handlers.
- Generated Gemini output has readable headings, paragraphs, and links.
- Generated Micron output fits terminal-first browsing.
@@ -166,6 +166,7 @@ python3 -m ampg --config examples/wownero.gateway.toml approvals list --profile
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 state --dry-run --profile mobile-i2p
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 start --dry-run --profile mobile-i2p
python3 -m ampg --config examples/i2p-only.gateway.toml plan
python3 -m ampg --config examples/i2p-only.gateway.toml build
```
@@ -178,4 +179,4 @@ python3 -m ampg --config examples/i2p-only.gateway.toml build
- [ ] Add Gemini conversion fixtures.
- [ ] Add Reticulum/Micron conversion fixtures.
- [ ] Enable I2P and Reticulum daemon management only after static outputs are stable.
- [ ] Copy only reviewed Wownero daemon and supervisor artifacts before service start.
- [ ] Start only AMPG-owned Wownero transport services after reviewed files are applied.
+142 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import unittest
from pathlib import Path
from ampg.apply import apply_state, apply_supervisor
from ampg.apply import CommandRunResult, apply_start, apply_state, apply_supervisor
from ampg.cli import main
from ampg.config import load_config
from ampg.platforms import PlatformProvider
@@ -99,6 +99,16 @@ def _linux_user_provider(root: Path) -> PlatformProvider:
)
def _termux_provider(root: Path) -> PlatformProvider:
return PlatformProvider(
name="android-termux",
process_supervisor="termux-services",
can_manage_daemons=True,
can_write_system_config=False,
state_root=root / "termux/var/lib/ampg",
)
def _run_state_apply(config_path: Path, root: Path) -> None:
config = load_config(config_path)
results = apply_state(
@@ -112,6 +122,32 @@ def _run_state_apply(config_path: Path, root: Path) -> None:
raise AssertionError(blocked[0].message)
def _run_termux_state_apply(config_path: Path, root: Path) -> None:
config = load_config(config_path)
results = apply_state(
config,
dry_run=False,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
)
blocked = [result for result in results if result.status == "blocked"]
if blocked:
raise AssertionError(blocked[0].message)
def _run_termux_supervisor_apply(config_path: Path, root: Path) -> None:
config = load_config(config_path)
results = apply_supervisor(
config,
dry_run=False,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
)
blocked = [result for result in results if result.status == "blocked"]
if blocked:
raise AssertionError(blocked[0].message)
class DeployApplyTest(unittest.TestCase):
def test_deploy_apply_state_blocks_unapproved_artifacts(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -338,6 +374,111 @@ class DeployApplyTest(unittest.TestCase):
self.assertTrue(loopback_run_exists)
self.assertTrue(daemon_run_executable)
def test_deploy_apply_start_cli_reports_missing_supervisor(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
status, _, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"state",
"--profile",
"mobile-i2p",
"--yes",
]
)
if status != 0:
raise AssertionError(error)
status, output, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"start",
"--dry-run",
"--profile",
"mobile-i2p",
]
)
self.assertEqual(1, status)
self.assertEqual("", error)
self.assertIn("AMPG_DEPLOY_START", output)
self.assertIn("deploy apply --stage supervisor first", output)
def test_deploy_apply_start_blocks_until_supervisor_is_installed(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
_run_termux_state_apply(config_path, root)
config = load_config(config_path)
results = apply_start(
config,
dry_run=True,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
)
self.assertTrue(any(result.status == "blocked" for result in results))
self.assertTrue(
any("deploy apply --stage supervisor first" in result.message for result in results)
)
def test_deploy_apply_start_dry_run_after_supervisor_apply(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
_run_termux_state_apply(config_path, root)
_run_termux_supervisor_apply(config_path, root)
config = load_config(config_path)
results = apply_start(
config,
dry_run=True,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
)
self.assertTrue(all(result.status == "planned" for result in results))
self.assertIn(("sv-enable", "ampg-example-i2p-i2pd"), [result.command for result in results])
def test_deploy_apply_start_live_invokes_runner(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
_run_termux_state_apply(config_path, root)
_run_termux_supervisor_apply(config_path, root)
config = load_config(config_path)
commands: list[tuple[str, ...]] = []
def runner(command: tuple[str, ...]) -> CommandRunResult:
commands.append(command)
return CommandRunResult(return_code=0)
results = apply_start(
config,
dry_run=False,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
command_runner=runner,
)
self.assertTrue(all(result.status == "started" for result in results))
self.assertIn(("sv-enable", "ampg-example-i2p-i2pd"), commands)
self.assertIn(("sv-enable", "ampg-example-i2p-nginx-loopback"), commands)
if __name__ == "__main__":
unittest.main()