feat(update): standalone updater runner (wait/install/restart/result)
This commit is contained in:
+108
-1
@@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -187,3 +186,111 @@ def spawn_updater(updater_copy: Path, plan_path: Path, parent_pid: int) -> subpr
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone runner (executed from the copied updater.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pid_alive_default(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def wait_for_pid_exit(pid, timeout, *, poll=0.5, sleep=time.sleep, pid_alive=None):
|
||||
alive = pid_alive or _pid_alive_default
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not alive(pid):
|
||||
return True
|
||||
sleep(poll)
|
||||
return not alive(pid)
|
||||
|
||||
|
||||
def _append_log(log_path: Path, text: str) -> None:
|
||||
with open(log_path, "a", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def _write_result(plan: dict, staging: Path, status: str, stage: str | None, message: str) -> None:
|
||||
payload = {
|
||||
"status": status,
|
||||
"stage": stage,
|
||||
"version": plan["version"],
|
||||
"previous_version": plan.get("previous_version", ""),
|
||||
"message": message,
|
||||
"log": plan["log_path"],
|
||||
}
|
||||
result_path = Path(plan["result_path"])
|
||||
result_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
# fixed read location for the status endpoint
|
||||
(staging / "last-result.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def run_plan(plan_path, *, parent_pid, sleep=time.sleep, pid_alive=None, run=subprocess.run, popen=subprocess.Popen):
|
||||
plan_path = Path(plan_path)
|
||||
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
staging = Path(plan["result_path"]).parent.parent # <staging>/v<ver>/update-result.json
|
||||
log_path = Path(plan["log_path"])
|
||||
_append_log(log_path, f"updater start: pid={os.getpid()} parent={parent_pid}\n")
|
||||
try:
|
||||
if not wait_for_pid_exit(parent_pid, PID_WAIT_TIMEOUT, sleep=sleep, pid_alive=pid_alive):
|
||||
_write_result(plan, staging, "failed", "wait", f"parent {parent_pid} still alive after {PID_WAIT_TIMEOUT}s")
|
||||
return 1
|
||||
|
||||
proc = run(plan["install_command"], capture_output=True, text=True, cwd=plan["cwd"])
|
||||
_append_log(log_path, f"install rc={proc.returncode}\n{proc.stdout}\n{proc.stderr}\n")
|
||||
if proc.returncode != 0:
|
||||
_write_result(plan, staging, "failed", "install", proc.stderr.strip() or "install failed")
|
||||
return 1
|
||||
|
||||
strategy = plan["restart"]["strategy"]
|
||||
if strategy == "cli":
|
||||
# The respawned service is long-lived, so we cannot judge success by
|
||||
# waiting on it; only a spawn failure (missing binary etc.) counts
|
||||
# as a restart failure.
|
||||
try:
|
||||
popen(
|
||||
plan["respawn_command"],
|
||||
cwd=plan["cwd"],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError as exc:
|
||||
_write_result(plan, staging, "installed_restart_failed", "restart", str(exc))
|
||||
return 1
|
||||
elif strategy == "systemd":
|
||||
unit = plan["restart"]["unit"]
|
||||
proc = run(["systemctl", "--user", "restart", unit], capture_output=True, text=True)
|
||||
_append_log(log_path, f"systemctl rc={proc.returncode}\n{proc.stdout}\n{proc.stderr}\n")
|
||||
if proc.returncode != 0:
|
||||
_write_result(
|
||||
plan, staging, "installed_restart_failed", "restart",
|
||||
f"systemctl --user restart {unit} failed; run it manually",
|
||||
)
|
||||
return 1
|
||||
|
||||
_write_result(plan, staging, "success", None, "update applied")
|
||||
return 0
|
||||
finally:
|
||||
release_plan_lock(plan_path)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(prog="EvoScientist.updater")
|
||||
parser.add_argument("--plan", required=True)
|
||||
parser.add_argument("--parent-pid", required=True, type=int)
|
||||
args = parser.parse_args(argv)
|
||||
return run_plan(Path(args.plan), parent_pid=args.parent_pid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for the standalone updater runner (run_plan / wait_for_pid_exit)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from EvoScientist.updater import run_plan, wait_for_pid_exit
|
||||
|
||||
|
||||
def test_wait_for_pid_exit_true_when_gone():
|
||||
alive = {"n": 3}
|
||||
|
||||
def pid_alive(pid):
|
||||
alive["n"] -= 1
|
||||
return alive["n"] > 0
|
||||
|
||||
assert wait_for_pid_exit(1234, timeout=10, poll=0, sleep=lambda s: None, pid_alive=pid_alive)
|
||||
|
||||
|
||||
def test_wait_for_pid_exit_timeout():
|
||||
assert not wait_for_pid_exit(1234, timeout=0.01, poll=0, sleep=lambda s: None, pid_alive=lambda p: True)
|
||||
|
||||
|
||||
def _write_plan(tmp_path, **over):
|
||||
target = tmp_path / "v1"
|
||||
target.mkdir()
|
||||
plan = {
|
||||
"version": "1.0.0",
|
||||
"artifact": str(target / "pkg.whl"),
|
||||
"install_command": ["true"],
|
||||
"restart": {"strategy": "cli", "unit": None},
|
||||
"respawn_command": ["echo", "restarted"],
|
||||
"cwd": str(tmp_path),
|
||||
"deploy_mode": "full",
|
||||
"previous_version": "0.9.9",
|
||||
"result_path": str(target / "update-result.json"),
|
||||
"log_path": str(target / "updater.log"),
|
||||
}
|
||||
plan.update(over)
|
||||
plan_path = target / "plan.json"
|
||||
plan_path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
return plan_path
|
||||
|
||||
|
||||
def test_run_plan_success(tmp_path):
|
||||
plan_path = _write_plan(tmp_path)
|
||||
rc = run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False)
|
||||
assert rc == 0
|
||||
result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text())
|
||||
assert result["status"] == "success"
|
||||
assert result["version"] == "1.0.0"
|
||||
last = json.loads((tmp_path / "last-result.json").read_text())
|
||||
assert last["status"] == "success"
|
||||
|
||||
|
||||
def test_run_plan_install_failure(tmp_path):
|
||||
plan_path = _write_plan(
|
||||
tmp_path,
|
||||
install_command=[sys.executable, "-c", "import sys; sys.exit(3)"],
|
||||
)
|
||||
rc = run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False)
|
||||
assert rc == 1
|
||||
result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text())
|
||||
assert result["status"] == "failed"
|
||||
assert result["stage"] == "install"
|
||||
|
||||
|
||||
def test_run_plan_restart_failure(tmp_path):
|
||||
plan_path = _write_plan(tmp_path, respawn_command=["/nonexistent/evosci-binary"])
|
||||
rc = run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False)
|
||||
assert rc == 1
|
||||
result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text())
|
||||
assert result["status"] == "installed_restart_failed"
|
||||
|
||||
|
||||
def test_run_plan_releases_plan_lock(tmp_path):
|
||||
plan_path = _write_plan(tmp_path)
|
||||
lock = plan_path.with_suffix(".lock")
|
||||
lock.write_text("")
|
||||
run_plan(plan_path, parent_pid=os.getpid(), pid_alive=lambda p: False)
|
||||
assert not lock.exists()
|
||||
|
||||
|
||||
def test_run_plan_parent_never_exits(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("EvoScientist.updater.PID_WAIT_TIMEOUT", 0.01)
|
||||
plan_path = _write_plan(tmp_path)
|
||||
rc = run_plan(
|
||||
plan_path,
|
||||
parent_pid=os.getpid(),
|
||||
sleep=lambda s: None,
|
||||
pid_alive=lambda p: True,
|
||||
)
|
||||
assert rc == 1
|
||||
result = json.loads(Path(json.loads(plan_path.read_text())["result_path"]).read_text())
|
||||
assert result["status"] == "failed"
|
||||
assert result["stage"] == "wait"
|
||||
Reference in New Issue
Block a user