Add supervisor stage deploy apply

This commit is contained in:
jwinterm
2026-07-06 21:47:30 -04:00
parent b2bf06e050
commit f276eb470b
10 changed files with 605 additions and 39 deletions
+212 -2
View File
@@ -5,8 +5,13 @@ from pathlib import Path
import shutil
from .config import GatewayConfig
from .install_plan import InstallStateCopy, install_state_copies
from .platforms import PlatformProvider
from .install_plan import (
InstallStateCopy,
InstallSupervisorAction,
install_state_copies,
install_supervisor_actions,
)
from .platforms import PlatformProvider, detect_platform
from .state_contract import gateway_state_root
from .status import DaemonProbeFunc
@@ -24,6 +29,21 @@ class StateApplyResult:
message: str
@dataclass(frozen=True)
class SupervisorApplyResult:
site_id: str
protocol: str
platform: str
kind: str
service: str
source: Path
target: Path
status: str
mode: str
command: str
message: str
def apply_state(
config: GatewayConfig,
*,
@@ -65,6 +85,66 @@ def apply_state(
return results
def apply_supervisor(
config: GatewayConfig,
*,
dry_run: bool,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[SupervisorApplyResult]:
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
}
results: list[SupervisorApplyResult] = []
for action in actions:
target = supervisor_target(provider, action)
validation = _validate_supervisor_action(
action,
target=target,
state_targets=state_targets,
dry_run=dry_run,
)
if validation.status == "blocked":
results.append(validation)
continue
if not dry_run:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(action.source, target)
_chmod_supervisor_target(provider, target)
results.append(
SupervisorApplyResult(
site_id=action.site_id,
protocol=action.protocol,
platform=action.platform,
kind=action.kind,
service=action.service,
source=action.source,
target=target,
status="planned" if dry_run else "written",
mode="dry-run" if dry_run else "live",
command=action.command,
message=(
"would install approved supervisor file"
if dry_run
else "installed approved supervisor file"
),
)
)
return results
def _validate_state_copy(
config: GatewayConfig,
copy: InstallStateCopy,
@@ -109,6 +189,76 @@ def _validate_state_copy(
)
def _validate_supervisor_action(
action: InstallSupervisorAction,
*,
target: Path,
state_targets: dict[tuple[str, str, str, str], Path],
dry_run: bool,
) -> SupervisorApplyResult:
mode = "dry-run" if dry_run else "live"
if action.status != "planned":
return _supervisor_result(
action,
target=target,
status="blocked",
mode=mode,
message=action.message,
)
if not _valid_service_name(action.service):
return _supervisor_result(
action,
target=target,
status="blocked",
mode=mode,
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:
return _supervisor_result(
action,
target=target,
status="blocked",
mode=mode,
message="no managed state config is planned for this supervisor",
)
if not state_target.exists():
return _supervisor_result(
action,
target=target,
status="blocked",
mode=mode,
message="managed state config is missing; run deploy apply --stage state first",
)
if not action.source.exists():
return _supervisor_result(
action,
target=target,
status="blocked",
mode=mode,
message="approved supervisor artifact is missing",
)
if action.source.is_dir():
return _supervisor_result(
action,
target=target,
status="blocked",
mode=mode,
message="approved supervisor artifact is a directory",
)
return _supervisor_result(
action,
target=target,
status="ready",
mode=mode,
message="supervisor file passed safety checks",
)
def _result(
copy: InstallStateCopy,
*,
@@ -129,6 +279,66 @@ def _result(
)
def _supervisor_result(
action: InstallSupervisorAction,
*,
target: Path,
status: str,
mode: str,
message: str,
) -> SupervisorApplyResult:
return SupervisorApplyResult(
site_id=action.site_id,
protocol=action.protocol,
platform=action.platform,
kind=action.kind,
service=action.service,
source=action.source,
target=target,
status=status,
mode=mode,
command=action.command,
message=message,
)
def supervisor_target(provider: PlatformProvider, action: InstallSupervisorAction) -> Path:
if provider.name == "linux-systemd":
return Path("/etc/systemd/system") / f"{action.service}.service"
if provider.name == "macos-launchd":
return Path("~/Library/LaunchAgents").expanduser() / f"org.ampg.{action.service}.plist"
if provider.name == "android-termux":
prefix = _termux_prefix(provider.state_root)
return prefix / "var/service" / action.service / "run"
return provider.state_root / "services" / action.service / action.source.name
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]
return state_root
def _required_state_kind(supervisor_kind: str) -> str | None:
if supervisor_kind == "daemon-supervisor":
return "daemon-config"
if supervisor_kind == "loopback-supervisor":
return "loopback-config"
return None
def _valid_service_name(service: str) -> bool:
if not service.startswith("ampg-"):
return False
return all(char.isalnum() or char in {"-", "_", "."} for char in service)
def _chmod_supervisor_target(provider: PlatformProvider, target: Path) -> None:
if provider.name not in {"android-termux", "linux-user"}:
return
target.chmod(target.stat().st_mode | 0o111)
def _is_relative_to(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
+45 -2
View File
@@ -21,7 +21,12 @@ from .addresses import (
effective_address_records,
set_address,
)
from .apply import StateApplyResult, apply_state
from .apply import (
StateApplyResult,
SupervisorApplyResult,
apply_state,
apply_supervisor,
)
from .approvals import (
ACTIVATION_ARTIFACT_KIND,
GENERIC_PLATFORM,
@@ -152,7 +157,7 @@ def main(argv: list[str] | None = None) -> int:
_add_target_selection(deploy_apply_parser)
deploy_apply_parser.add_argument(
"--stage",
choices=("state",),
choices=("state", "supervisor"),
required=True,
help="Deployment stage to apply.",
)
@@ -592,6 +597,27 @@ def _cmd_deploy(config, args) -> int:
f"blocked={len(blocked)}"
)
return 1 if blocked else 0
if args.stage == "supervisor":
results = apply_supervisor(
config,
dry_run=args.dry_run,
platform_provider=platform_provider,
)
for result in results:
_print_supervisor_apply_result(result)
blocked = [result for result in results if result.status == "blocked"]
written = [result for result in results if result.status == "written"]
planned = [result for result in results if result.status == "planned"]
print(
"AMPG_DEPLOY_APPLY_SUMMARY "
f"stage=supervisor "
f"mode={'dry-run' if args.dry_run else 'live'} "
f"results={len(results)} "
f"planned={len(planned)} "
f"written={len(written)} "
f"blocked={len(blocked)}"
)
return 1 if blocked else 0
return 1
@@ -1080,6 +1106,23 @@ def _print_state_apply_result(result: StateApplyResult) -> None:
)
def _print_supervisor_apply_result(result: SupervisorApplyResult) -> None:
print(
"AMPG_DEPLOY_SUPERVISOR "
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"source=\"{_quote(str(result.source))}\" "
f"target=\"{_quote(str(result.target))}\" "
f"status={result.status} "
f"command=\"{_quote(result.command)}\" "
f"message=\"{_quote(result.message)}\""
)
def _print_dns_record(record: DNSRecordPlan) -> None:
print(
"AMPG_DNS_RECORD "
+73 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import shlex
from .activation import activation_steps, apply_preflight
from .apply import supervisor_target
from .addresses import effective_address_records
from .approvals import (
ACTIVATION_ARTIFACT_KIND,
@@ -22,7 +23,7 @@ from .install_plan import (
install_supervisor_actions,
)
from .plan import plan_artifacts
from .platforms import PlatformProvider
from .platforms import PlatformProvider, detect_platform
from .status import DaemonProbeFunc, doctor_gateway
@@ -88,6 +89,12 @@ def deploy_plan(
platform_provider=platform_provider,
daemon_probe=daemon_probe,
),
_deploy_apply_step(
config,
command_context,
platform_provider=platform_provider,
daemon_probe=daemon_probe,
),
)
status = _overall_status(steps)
return DeployPlan(
@@ -308,6 +315,71 @@ def _apply_preflight_step(
)
def _deploy_apply_step(
config: GatewayConfig,
commands: _CommandContext,
*,
platform_provider: PlatformProvider | None,
daemon_probe: DaemonProbeFunc | None,
) -> DeployStep:
provider = platform_provider or detect_platform()
state_copies = install_state_copies(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
supervisor_actions = install_supervisor_actions(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
review = [
item
for item in (*state_copies, *supervisor_actions)
if item.status != "planned"
]
if review:
return DeployStep(
"deploy-apply",
"review",
commands.command("apply --dry-run", platform=True),
f"review {len(review)} staged apply input(s)",
)
missing_state = [copy for copy in state_copies if not copy.target.exists()]
if missing_state:
return DeployStep(
"deploy-apply",
"todo",
commands.command("deploy apply --stage state --dry-run", platform=True),
f"copy {len(missing_state)} approved state artifact(s)",
)
missing_supervisors = [
action
for action in supervisor_actions
if not supervisor_target(provider, action).exists()
]
if missing_supervisors:
return DeployStep(
"deploy-apply",
"todo",
commands.command("deploy apply --stage supervisor --dry-run", platform=True),
f"install {len(missing_supervisors)} approved supervisor file(s)",
)
if state_copies or supervisor_actions:
return DeployStep(
"deploy-apply",
"ready",
commands.command("deploy apply --stage supervisor --dry-run", platform=True),
"staged deploy files are applied",
)
return DeployStep(
"deploy-apply",
"ready",
"-",
"no managed deploy apply stages are needed",
)
def _approval_candidates(
config: GatewayConfig,
platform_provider: PlatformProvider | None,