feat(update): plan builder, plan lock and updater spawn

This commit is contained in:
m4
2026-08-12 16:27:39 +08:00
parent 40b896bcb5
commit 90e773b30b
2 changed files with 146 additions and 0 deletions
+94
View File
@@ -93,3 +93,97 @@ def build_install_command(kind: str, artifact: Path, python: str) -> list[str]:
if kind == DEPLOY_PIPX:
return ["pipx", "install", "--force", str(artifact)]
return [python, "-m", "pip", "install", "--force-reinstall", str(artifact)]
class UpdateInProgressError(RuntimeError):
"""Another update holds the plan lock."""
def build_plan(
*,
version: str,
artifact: Path,
kind: str,
python: str,
argv: Sequence[str],
cwd: Path,
deploy_mode: str,
staging: Path,
systemd_unit: str | None,
) -> dict:
target_dir = staging / f"v{version}"
if kind == DEPLOY_SYSTEMD:
strategy, unit = "systemd", systemd_unit or "evoscientist.service"
elif kind == DEPLOY_DOCKER:
strategy, unit = "none", None
else:
strategy, unit = "cli", None
try:
from importlib.metadata import version as _pkg_version
previous = _pkg_version("EvoScientist")
except Exception:
previous = "0.0.0-dev"
return {
"version": version,
"artifact": str(artifact),
"install_command": build_install_command(kind, artifact, python),
"restart": {"strategy": strategy, "unit": unit},
"respawn_command": list(argv),
"cwd": str(cwd),
"deploy_mode": deploy_mode,
"previous_version": previous,
"result_path": str(target_dir / "update-result.json"),
"log_path": str(target_dir / "updater.log"),
}
def _lock_path(plan_path: Path) -> Path:
return plan_path.with_suffix(".lock")
def write_plan_locked(plan: dict, plan_path: Path) -> None:
"""Write plan.json, claiming the update lock atomically.
Lock semantics: the lock FILE existing means an update is in progress.
Uses O_CREAT|O_EXCL instead of filelock so this module stays stdlib-only
for the copied runner; the lock is released by release_plan_lock (called
by the runner on completion or by the next operation after a crash).
"""
plan_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = _lock_path(plan_path)
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
raise UpdateInProgressError(str(plan_path)) from None
else:
os.close(fd)
plan_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")
def release_plan_lock(plan_path: Path) -> None:
_lock_path(plan_path).unlink(missing_ok=True)
def copy_updater(dest_dir: Path) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / "updater.py"
shutil.copyfile(Path(__file__).resolve(), dest)
return dest
def spawn_updater(updater_copy: Path, plan_path: Path, parent_pid: int) -> subprocess.Popen:
return subprocess.Popen(
[
sys.executable,
str(updater_copy),
"--plan",
str(plan_path),
"--parent-pid",
str(parent_pid),
],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
)
+52
View File
@@ -13,8 +13,13 @@ from EvoScientist.updater import (
DEPLOY_UVTOOL,
DEPLOY_VENV,
SystemProbe,
UpdateInProgressError,
build_install_command,
build_plan,
copy_updater,
detect_deployment,
release_plan_lock,
write_plan_locked,
)
@@ -50,3 +55,50 @@ def test_install_commands():
]
systemd_cmd = build_install_command(DEPLOY_SYSTEMD, artifact, "/usr/bin/python3")
assert systemd_cmd[0:2] == ["uv", "tool"] or systemd_cmd[0] == "/usr/bin/python3"
def _plan(tmp_path):
artifact = tmp_path / "v0.2.3" / "EvoScientist-0.2.3-py3-none-any.whl"
artifact.parent.mkdir(parents=True)
artifact.write_bytes(b"x")
return build_plan(
version="0.2.3",
artifact=artifact,
kind=DEPLOY_UVTOOL,
python="/usr/bin/python3",
argv=["EvoSci", "deploy", "--port", "6174"],
cwd=tmp_path,
deploy_mode="full",
staging=tmp_path,
systemd_unit=None,
)
def test_build_plan_fields(tmp_path):
plan = _plan(tmp_path)
assert plan["version"] == "0.2.3"
assert plan["install_command"][0:3] == ["uv", "tool", "install"]
assert plan["respawn_command"] == ["EvoSci", "deploy", "--port", "6174"]
assert plan["cwd"] == str(tmp_path)
assert plan["deploy_mode"] == "full"
assert plan["restart"] == {"strategy": "cli", "unit": None}
assert plan["result_path"].endswith("update-result.json")
assert plan["previous_version"]
def test_write_plan_locked_rejects_second_writer(tmp_path):
plan = _plan(tmp_path)
plan_path = tmp_path / "v0.2.3" / "plan.json"
write_plan_locked(plan, plan_path)
assert json.loads(plan_path.read_text())["version"] == "0.2.3"
with pytest.raises(UpdateInProgressError):
write_plan_locked(plan, plan_path)
release_plan_lock(plan_path)
write_plan_locked(plan, plan_path) # releasable and writable again
release_plan_lock(plan_path)
def test_copy_updater_produces_standalone_copy(tmp_path):
copied = copy_updater(tmp_path)
assert copied == tmp_path / "updater.py"
assert "detect_deployment" in copied.read_text(encoding="utf-8")