feat(update): plan builder, plan lock and updater spawn
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user