212 lines
9.3 KiB
Python
212 lines
9.3 KiB
Python
"""Real startup paths share one passive PM verdict, including fast dispatch."""
|
|
import importlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.parametrize("surface", ["serve", "version", "gateway"])
|
|
def test_each_start_checks_pm_once_before_dispatch(surface, tmp_path, monkeypatch, capsys, caplog):
|
|
import pm
|
|
from hermes_cli import boot_bootstrap
|
|
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
runtime = tmp_path / "tools"
|
|
runtime.mkdir()
|
|
# A recorded but empty store is genuinely drifted against the shipped lock.
|
|
(runtime / "facts.json").write_text(json.dumps({"schema": 1, "packages": {}}))
|
|
root = tmp_path / "payload"
|
|
root.mkdir()
|
|
(root / "install-stamp.json").write_text(json.dumps({
|
|
"commit": "abcdef012345", "payload": "bundled", "updateMechanism": "electron-updater",
|
|
}))
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime))
|
|
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(root))
|
|
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
|
monkeypatch.delenv("HERMES_DISABLE_FAST_SERVE_LAUNCH", raising=False)
|
|
checked = []
|
|
real_check = pm.check
|
|
|
|
def check(**kwargs):
|
|
problems = real_check(**kwargs)
|
|
assert problems
|
|
checked.append(problems)
|
|
return problems
|
|
|
|
def no_install(*args, **kwargs):
|
|
pytest.fail("startup must not install or sync")
|
|
|
|
monkeypatch.setattr(importlib.import_module("pm.install"), "check", check)
|
|
monkeypatch.setattr(pm, "ensure", no_install)
|
|
monkeypatch.setattr(pm, "sync_venv", no_install)
|
|
dispatched = []
|
|
if surface == "gateway":
|
|
import gateway.run as gr
|
|
|
|
async def start(*args, **kwargs):
|
|
dispatched.append(len(checked))
|
|
return True
|
|
|
|
monkeypatch.setattr(gr, "start_gateway", start)
|
|
monkeypatch.setattr(gr, "_exit_after_graceful_shutdown", lambda code: None)
|
|
monkeypatch.setattr(sys, "argv", ["gateway"])
|
|
run = gr.main
|
|
else:
|
|
from hermes_cli import main
|
|
|
|
monkeypatch.setattr(main, "cmd_dashboard", lambda args: dispatched.append(len(checked)))
|
|
monkeypatch.setattr(main, "cmd_version", lambda args: dispatched.append(len(checked)))
|
|
monkeypatch.setattr(sys, "argv", ["hermes", "serve" if surface == "serve" else "--version"])
|
|
run = main.main
|
|
for _ in range(2):
|
|
checked.clear()
|
|
capsys.readouterr()
|
|
caplog.clear()
|
|
run()
|
|
assert len(checked) == 1
|
|
assert dispatched[-1] == 1
|
|
output = capsys.readouterr().err
|
|
warnings = [r for r in caplog.records if "install out of sync" in r.message]
|
|
if surface == "gateway":
|
|
assert len(warnings) == 1
|
|
else:
|
|
assert output.count("install out of sync") == 1
|
|
assert "rebuild the artifact" in (warnings[0].message if surface == "gateway" else output)
|
|
assert not list(home.rglob("machine.json"))
|
|
assert boot_bootstrap.current_install_identity(root) == "abcdef012345"
|
|
|
|
|
|
@pytest.mark.parametrize("sibling_profile", [False, True])
|
|
def test_concurrent_boots_bound_real_home_migration(tmp_path, monkeypatch, sibling_profile):
|
|
import os
|
|
import subprocess
|
|
import sqlite3
|
|
import time
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
root = tmp_path / "payload"
|
|
root.mkdir()
|
|
(root / "install-stamp.json").write_text(json.dumps({
|
|
"commit": "abcdef012345", "updateMechanism": "external", "distribution": "nix",
|
|
}))
|
|
home = tmp_path / "home"
|
|
other = home / "profiles/coder" if sibling_profile else home
|
|
for directory in {home, other}:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(directory / "config.yaml").write_text(f"_config_version: {DEFAULT_CONFIG['_config_version'] - 1}\n")
|
|
(directory / ".env").write_text("EXAMPLE_KEY=test\n")
|
|
with sqlite3.connect(directory / "state.db") as db:
|
|
db.execute("CREATE TABLE retained (value)")
|
|
db.execute("INSERT INTO retained VALUES ('before')")
|
|
entered = tmp_path / "entered"
|
|
release = tmp_path / "release"
|
|
script = tmp_path / "boot.py"
|
|
script.write_text('''import json, sys, time
|
|
from pathlib import Path
|
|
from hermes_cli import boot_bootstrap, post_update
|
|
root, entered, release = map(Path, sys.argv[1:4])
|
|
def migrate():
|
|
if sys.argv[4] == 'hold':
|
|
entered.touch()
|
|
deadline = time.monotonic() + 30
|
|
while not release.exists():
|
|
if time.monotonic() > deadline: raise RuntimeError('release timeout')
|
|
time.sleep(0.02)
|
|
return post_update.step_migrate_config()
|
|
post_update.BOOT_HOME_STEPS = (('migrate', migrate), ('db', post_update.step_state_db_guard))
|
|
print(json.dumps(boot_bootstrap.run_boot_bootstrap(root)))
|
|
''')
|
|
env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(home),
|
|
HERMES_RUNTIME_DIR=str(tmp_path / "tools"),
|
|
PYTHONPATH=str(Path(__file__).resolve().parents[2]))
|
|
command = [sys.executable, str(script), str(root), str(entered), str(release)]
|
|
first = subprocess.Popen([*command, "hold"], env=env, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, text=True)
|
|
try:
|
|
deadline = time.monotonic() + 30
|
|
while not entered.exists():
|
|
assert first.poll() is None
|
|
assert time.monotonic() < deadline
|
|
time.sleep(0.02)
|
|
second = subprocess.run([*command, "go"], env=dict(env, HERMES_HOME=str(other)),
|
|
capture_output=True, text=True, timeout=30, check=False)
|
|
assert second.returncode == 0, second.stderr
|
|
result = json.loads(second.stdout.splitlines()[-1])
|
|
if sibling_profile:
|
|
assert result["home"]["migrate"]["ok"]
|
|
assert result["home"]["db"]["ok"]
|
|
else:
|
|
assert result == {"home": "lost-race"}
|
|
release.touch()
|
|
stdout, stderr = first.communicate(timeout=30)
|
|
assert first.returncode == 0, stderr
|
|
assert json.loads(stdout.splitlines()[-1])["home"]["db"]["ok"]
|
|
again = subprocess.run([*command, "go"], env=env, capture_output=True, text=True, timeout=30, check=False)
|
|
assert json.loads(again.stdout.splitlines()[-1]) == {"home": "skipped"}
|
|
for directory in {home, other}:
|
|
assert len(list(directory.glob("config.yaml.bak-*"))) == 1
|
|
with sqlite3.connect(directory / "state.db") as db:
|
|
assert db.execute("SELECT value FROM retained").fetchall() == [("before",)]
|
|
# A changed artifact or Git identity must run the real migration again.
|
|
for revision in ('sealed', 'git'):
|
|
(home / 'config.yaml').write_text(f"_config_version: {DEFAULT_CONFIG['_config_version'] - 1}\n")
|
|
if revision == 'sealed':
|
|
(root / 'install-stamp.json').write_text(json.dumps({
|
|
'commit': 'fedcba987654', 'updateMechanism': 'external'}))
|
|
else:
|
|
subprocess.run(['git', 'init', '-q', str(root)], check=True)
|
|
subprocess.run(['git', '-c', 'user.name=Fixture', '-c', 'user.email=t@example.invalid',
|
|
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-qm', 'revision'],
|
|
cwd=root, check=True)
|
|
changed = subprocess.run([*command, 'go'], env=env, capture_output=True, text=True, timeout=30, check=False)
|
|
assert changed.returncode == 0, changed.stderr
|
|
assert json.loads(changed.stdout.splitlines()[-1])['home']['migrate']['ok']
|
|
import hermes_yaml
|
|
assert hermes_yaml.safe_load((home / 'config.yaml').read_text())['_config_version'] == DEFAULT_CONFIG['_config_version']
|
|
finally:
|
|
release.touch()
|
|
if first.poll() is None:
|
|
first.kill()
|
|
first.communicate(timeout=30)
|
|
|
|
|
|
def test_failed_migration_is_restored_and_not_retried(tmp_path, monkeypatch):
|
|
from hermes_cli import boot_bootstrap, config, post_update
|
|
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
root = tmp_path / "payload"
|
|
root.mkdir()
|
|
(root / "install-stamp.json").write_text(json.dumps({
|
|
"commit": "abcdef012345", "updateMechanism": "external", "distribution": "nix",
|
|
}))
|
|
config_file = home / "config.yaml"
|
|
config_file.write_text(f"_config_version: {config.DEFAULT_CONFIG['_config_version'] - 1}\n")
|
|
env_file = home / ".env"
|
|
env_file.write_text("EXAMPLE_KEY=keep\n")
|
|
original = {p: p.read_bytes() for p in (config_file, env_file)}
|
|
calls = []
|
|
|
|
def interrupted(**kwargs):
|
|
calls.append(True)
|
|
config_file.write_text("half written")
|
|
env_file.write_text("half written")
|
|
raise RuntimeError("interrupted migration")
|
|
|
|
monkeypatch.setattr(config, "migrate_config", interrupted)
|
|
monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", (("migrate", post_update.step_migrate_config),))
|
|
assert not boot_bootstrap.run_boot_bootstrap(root)["home"]["migrate"]["ok"]
|
|
assert {p: p.read_bytes() for p in original} == original
|
|
assert boot_bootstrap.run_boot_bootstrap(root) == {"home": "skipped"}
|
|
assert calls == [True]
|
|
record = boot_bootstrap.read_last_known(boot_bootstrap.record_path(root))
|
|
assert record['identity'] == 'abcdef012345'
|
|
assert record['results']['migrate']['ok'] is False
|