mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add start stage deploy apply
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user