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 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 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 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 `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 reachability hints. `apply --dry-run` prints the activation sequence and a preflight
verdict without changing services. `deploy apply --stage state` copies approved verdict without changing services. `deploy apply --stage state` copies approved
managed-daemon config into AMPG-owned state. `deploy apply --stage supervisor` installs managed-daemon config into AMPG-owned state. `deploy apply --stage supervisor` installs
approved supervisor files after state exists. Service start remains a separate future approved supervisor files after state exists. `deploy apply --stage start` starts
stage. AMPG-owned services after state and supervisor files are present.
## Interaction tiers ## 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 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 --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 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
python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p 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 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, 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. 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 `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; 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 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 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 state --dry-run`; confirm approved state copies.
- [ ] Run `ampg deploy apply --stage supervisor --dry-run`; confirm approved service files. - [ ] 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. - [ ] Run live apply after the preflight gate is ready.
+271
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import shlex
import subprocess
from .config import GatewayConfig from .config import GatewayConfig
from .install_plan import ( from .install_plan import (
@@ -44,6 +48,31 @@ class SupervisorApplyResult:
message: str 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( def apply_state(
config: GatewayConfig, config: GatewayConfig,
*, *,
@@ -145,6 +174,85 @@ def apply_supervisor(
return results 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( def _validate_state_copy(
config: GatewayConfig, config: GatewayConfig,
copy: InstallStateCopy, 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( def _result(
copy: InstallStateCopy, 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: def supervisor_target(provider: PlatformProvider, action: InstallSupervisorAction) -> Path:
if provider.name == "linux-systemd": if provider.name == "linux-systemd":
return Path("/etc/systemd/system") / f"{action.service}.service" 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 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: def _termux_prefix(state_root: Path) -> Path:
if len(state_root.parts) >= 3 and state_root.parts[-3:] == ("var", "lib", "ampg"): if len(state_root.parts) >= 3 and state_root.parts[-3:] == ("var", "lib", "ampg"):
return state_root.parents[2] 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) 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: def _is_relative_to(path: Path, parent: Path) -> bool:
try: try:
path.relative_to(parent) path.relative_to(parent)
+43 -1
View File
@@ -23,9 +23,12 @@ from .addresses import (
) )
from .apply import ( from .apply import (
StateApplyResult, StateApplyResult,
StartApplyResult,
SupervisorApplyResult, SupervisorApplyResult,
apply_state, apply_state,
apply_start,
apply_supervisor, apply_supervisor,
format_command,
) )
from .approvals import ( from .approvals import (
ACTIVATION_ARTIFACT_KIND, ACTIVATION_ARTIFACT_KIND,
@@ -157,7 +160,7 @@ def main(argv: list[str] | None = None) -> int:
_add_target_selection(deploy_apply_parser) _add_target_selection(deploy_apply_parser)
deploy_apply_parser.add_argument( deploy_apply_parser.add_argument(
"--stage", "--stage",
choices=("state", "supervisor"), choices=("state", "supervisor", "start"),
required=True, required=True,
help="Deployment stage to apply.", help="Deployment stage to apply.",
) )
@@ -618,6 +621,27 @@ def _cmd_deploy(config, args) -> int:
f"blocked={len(blocked)}" f"blocked={len(blocked)}"
) )
return 1 if blocked else 0 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 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: def _print_dns_record(record: DNSRecordPlan) -> None:
print( print(
"AMPG_DNS_RECORD " "AMPG_DNS_RECORD "
+13 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import shlex import shlex
from .activation import activation_steps, apply_preflight 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 .addresses import effective_address_records
from .approvals import ( from .approvals import (
ACTIVATION_ARTIFACT_KIND, ACTIVATION_ARTIFACT_KIND,
@@ -366,6 +366,18 @@ def _deploy_apply_step(
f"install {len(missing_supervisors)} approved supervisor file(s)", f"install {len(missing_supervisors)} approved supervisor file(s)",
) )
if state_copies or supervisor_actions: 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( return DeployStep(
"deploy-apply", "deploy-apply",
"ready", "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. only approved generated daemon config into AMPG-owned state.
`ampg deploy apply --stage supervisor` installs approved AMPG-named supervisor files `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 after the state files they reference exist. It does not start or reload services.
start, address capture, and health verification remain separate stages.
`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 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 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 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 --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 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 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 AMPG-named service files for the selected platform. It does not install packages, start
services, reload service managers, or delete service files. 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 `--protocol` scopes operational commands to selected enabled protocols. A clearnet
`adopt` failure will block a full activation run, but it will not block `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. `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 `deploy apply --stage supervisor` performs the approved supervisor-file installation
portion only. It requires copied managed state config first, writes AMPG-named service 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 files for the selected platform, and leaves service-manager start commands for the next
later explicit stage. 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 build` writes a fixture manifest that AMPB can check.
- `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live - `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live
transport daemons are installed or adopted. transport daemons are installed or adopted.
- `ampg deploy apply --stage state --dry-run` and `--stage supervisor --dry-run` show - `ampg deploy apply --stage state`, `--stage supervisor`, and `--stage start` stage
approved managed-daemon files before any Wownero transport service is started. Wownero transport services without mixing config writes and service start.
- Tor output contains no `<script>` tags or inline event handlers. - Tor output contains no `<script>` tags or inline event handlers.
- Generated Gemini output has readable headings, paragraphs, and links. - Generated Gemini output has readable headings, paragraphs, and links.
- Generated Micron output fits terminal-first browsing. - 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 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 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 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 plan
python3 -m ampg --config examples/i2p-only.gateway.toml build 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 Gemini conversion fixtures.
- [ ] Add Reticulum/Micron conversion fixtures. - [ ] Add Reticulum/Micron conversion fixtures.
- [ ] Enable I2P and Reticulum daemon management only after static outputs are stable. - [ ] 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 import unittest
from pathlib import Path 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.cli import main
from ampg.config import load_config from ampg.config import load_config
from ampg.platforms import PlatformProvider 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: def _run_state_apply(config_path: Path, root: Path) -> None:
config = load_config(config_path) config = load_config(config_path)
results = apply_state( results = apply_state(
@@ -112,6 +122,32 @@ def _run_state_apply(config_path: Path, root: Path) -> None:
raise AssertionError(blocked[0].message) 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): class DeployApplyTest(unittest.TestCase):
def test_deploy_apply_state_blocks_unapproved_artifacts(self): def test_deploy_apply_state_blocks_unapproved_artifacts(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -338,6 +374,111 @@ class DeployApplyTest(unittest.TestCase):
self.assertTrue(loopback_run_exists) self.assertTrue(loopback_run_exists)
self.assertTrue(daemon_run_executable) 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__": if __name__ == "__main__":
unittest.main() unittest.main()