141 lines
8.0 KiB
Python
141 lines
8.0 KiB
Python
"""Fresh takeover bootstrap prepares actual dependencies before completion."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.store import current_target, tree_digest
|
|
from tests.pm._fixtures import _wheel
|
|
|
|
|
|
@pytest.mark.platforms("linux")
|
|
def test_fresh_takeover_prepares_generation_and_runs_selected_python(tmp_path):
|
|
source = Path(__file__).resolve().parents[2]
|
|
root = tmp_path / "source with spaces"
|
|
root.mkdir()
|
|
for name in ("pm", "hermes_cli"):
|
|
shutil.copytree(source / name, root / name, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
for name in ("hermes_constants.py", "hermes_yaml.py", "utils.py", "hermes_bootstrap.py"):
|
|
shutil.copy2(source / name, root / name)
|
|
home, store, wheels = (tmp_path / name for name in ("home", "tools", "wheels"))
|
|
for directory in (home, store, wheels):
|
|
directory.mkdir()
|
|
_wheel(wheels, "takeover_dep", "1.0")
|
|
(root / "pyproject.toml").write_text(
|
|
'[project]\nname="takeover-fixture"\nversion="1"\nrequires-python=">=3.14"\n'
|
|
'dependencies=["takeover-dep==1.0"]\n[project.optional-dependencies]\nall=[]\nmatrix=[]\n'
|
|
'[tool.hermes.extras-platforms]\nmatrix="sys_platform == \'no-such-platform\'"\n'
|
|
'[tool.uv]\npackage=false\nno-index=true\n'
|
|
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8")
|
|
(root / ".git").mkdir()
|
|
(root / "install-stamp.json").write_text('{"updateMechanism":"self"}', encoding="utf-8")
|
|
uv = shutil.which("uv")
|
|
assert uv
|
|
env = {key: value for key, value in os.environ.items()
|
|
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
|
|
env.update(HOME=str(home), HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store), UV_PYTHON_DOWNLOADS="never")
|
|
subprocess.run([uv, "lock", "--offline", "--python", sys.executable], cwd=root, env=env, check=True, capture_output=True)
|
|
# A main-era venv that carried a gated extra: the takeover interpreter
|
|
# (-S, so no `packaging`) must still judge the gate and drop it.
|
|
legacy_site = root / ".venv" / "lib" / "python3.11" / "site-packages"
|
|
for anchor in ("mautrix", "asyncpg", "aiosqlite", "markdown", "aiohttp_socks"):
|
|
(legacy_site / anchor).mkdir(parents=True)
|
|
# Only the interpreter and private manager tool are needed by this tiny
|
|
# application. They are real host tools; PM's worker and resolver stay real.
|
|
(root / "pm/lock.json").write_text('{"schema":1,"packages":{}}', encoding="utf-8")
|
|
lock = Lockfile(root / "pm/lock.json")
|
|
target = current_target()
|
|
for name, executable, rel in (("python", sys._base_executable, "bin/python3"), ("uv", uv, "uv")):
|
|
entry = store / name
|
|
binary = entry / rel
|
|
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
binary.symlink_to(Path(executable).resolve())
|
|
if name == "uv":
|
|
(entry / "uvx").symlink_to(Path(uv).resolve().with_name("uvx"))
|
|
lock.set_pin(name, "fixture", {})
|
|
Facts(store / "facts.json").record(name, "fixture", name, {"PATH": [str(binary.parent)]}, store,
|
|
target=target, digest=tree_digest(entry))
|
|
# The application lock also pins build-only suppliers. A CLI takeover must
|
|
# not select them as runtime roots, even when they cannot run on this host.
|
|
lock.set_pin("dmgbuild", "fixture", {})
|
|
lock.save()
|
|
# Probe the selected-interpreter boundary; app completion orchestration has
|
|
# its own contract tests. This script cannot import the dep from the parent.
|
|
(root / "hermes_cli/update_finish.py").write_text(
|
|
"import json, sys\nfrom pathlib import Path\n"
|
|
"request=json.loads(Path(sys.argv[1]).read_text())\n"
|
|
"sys.path.insert(0,request['root'])\n"
|
|
"from pm.environments import activate_dependencies\n"
|
|
"activate_dependencies(Path(request['root']))\nimport takeover_dep\n"
|
|
"assert request['pm_receipt']['update_id']==request['update_id']\n"
|
|
"Path(sys.argv[2]).write_text(json.dumps({'python':sys.executable,'dep':takeover_dep.__file__}))\n",
|
|
encoding="utf-8")
|
|
context, result = tmp_path / "context.json", tmp_path / "result.json"
|
|
context.write_text(json.dumps({"root": str(root), "home": str(home)}), encoding="utf-8")
|
|
completed = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)],
|
|
env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120)
|
|
assert completed.returncode == 0, completed.stdout + completed.stderr
|
|
output = json.loads(result.read_text())
|
|
assert Path(output['python']).is_relative_to(store)
|
|
assert Path(output['dep']).is_relative_to(home / "installs")
|
|
assert (root / ".hermes/bin/hermes").is_file()
|
|
assert not (root / "venv").exists()
|
|
assert Facts(store / "facts.json").get("dmgbuild") is None
|
|
venv_fact = json.loads(next((home / "installs").glob("*/facts.json")).read_text())["packages"]["venv"]
|
|
assert "matrix" not in venv_fact["extras"]
|
|
|
|
# Repair restores the recorded graph; a checkout update must also advance
|
|
# that graph to the new source inputs before normal bootstrap checks it.
|
|
facts = next((home / "installs").glob("*/facts.json"))
|
|
first_stamp = json.loads(facts.read_text())["packages"]["venv"]["stamp"]
|
|
generations = facts.parent / "environments"
|
|
first_generations = {path.resolve() for path in generations.iterdir() if path.is_dir()}
|
|
assert first_generations
|
|
for generation in first_generations:
|
|
marker = generation / ".lease-managed"
|
|
assert marker.is_file()
|
|
os.utime(marker, (0, 0))
|
|
(facts.parent / ".repair-incomplete").write_text("{}", encoding="utf-8")
|
|
with (root / "uv.lock").open("a", encoding="utf-8") as changed:
|
|
changed.write("\n# changed source inputs\n")
|
|
repaired = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)],
|
|
env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120)
|
|
assert repaired.returncode == 0, repaired.stdout + repaired.stderr
|
|
assert json.loads(facts.read_text())["packages"]["venv"]["stamp"] != first_stamp
|
|
remaining_generations = {path.resolve() for path in generations.iterdir() if path.is_dir()}
|
|
assert first_generations.isdisjoint(remaining_generations)
|
|
assert not (facts.parent / ".repair-incomplete").exists()
|
|
|
|
# A child which dies before acknowledging receipt ownership must not
|
|
# leave the parent reporting success from its pre-handoff receipt.
|
|
(root / "hermes_cli/update_finish.py").write_text("raise SystemExit(7)\n", encoding="utf-8")
|
|
result.unlink()
|
|
crashed = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)],
|
|
env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120)
|
|
assert crashed.returncode == 7
|
|
assert json.loads(result.read_text())["receipt_handled"]
|
|
crash_receipt = json.loads((home / "logs/update_receipts/latest.json").read_text())
|
|
assert crash_receipt["exit_code"] == 7 and crash_receipt["outcome"] == "failed"
|
|
|
|
# A failed preparation must not delegate to application completion or
|
|
# replace the working generation, and must leave a truthful receipt.
|
|
facts = next((home / "installs").glob("*/facts.json"))
|
|
before = facts.read_bytes()
|
|
(root / "uv.lock").write_text("not valid TOML [", encoding="utf-8")
|
|
result.unlink()
|
|
failed = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)],
|
|
env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120)
|
|
assert failed.returncode != 0
|
|
assert facts.read_bytes() == before
|
|
failure = json.loads(result.read_text())
|
|
assert failure["receipt_handled"] is True
|
|
latest = json.loads((home / "logs/update_receipts/latest.json").read_text())
|
|
assert latest["outcome"] == "failed"
|