Files
hermes-agent/tests/tools/test_docker_config_migrate.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

259 lines
10 KiB
Python

from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
import pytest
import hermes_yaml as yaml
from hermes_cli.config import DEFAULT_CONFIG
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "docker_config_migrate.py"
def _load_script_module():
spec = importlib.util.spec_from_file_location("docker_config_migrate_test_module", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run_migration(hermes_home: Path, **env_overrides: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env.update(
{
"HERMES_HOME": str(hermes_home),
"HERMES_SKIP_CHMOD": "1",
"PYTHONPATH": str(REPO_ROOT),
}
)
env.update(env_overrides)
return subprocess.run(
[sys.executable, str(SCRIPT)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
)
def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 12,
"model_catalog": {"ttl_hours": 24},
"delegation": {"max_async_children": 8},
}
),
encoding="utf-8",
)
env_path.write_text("OPENROUTER_API_KEY=test\n", encoding="utf-8")
proc = _run_migration(tmp_path)
assert proc.returncode == 0, proc.stderr
assert "Migrating config schema 12 ->" in proc.stdout
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
# v24→25 lowers the old default model_catalog TTL to 1h, v39→40 drops
# that default so ttl_minutes (20) applies; v32→33 folds
# max_async_children into max_concurrent_children.
assert "ttl_hours" not in raw["model_catalog"]
assert raw["delegation"] == {"max_concurrent_children": 8}
assert list((tmp_path / "backups" / "config").glob("config.yaml.pre-docker-migrate.*"))
assert list((tmp_path / "backups" / "config").glob(".env.pre-docker-migrate.*"))
def test_docker_config_migrate_skips_below_floor_config_untouched(tmp_path: Path) -> None:
"""Configs below the v12 auto-migration support floor are refused with a
warning: no migration, no backup, no rewrite — and the boot continues."""
config_path = tmp_path / "config.yaml"
original = (
yaml.safe_dump(
{
"_config_version": 11,
"custom_providers": [
{
"name": "Local API",
"base_url": "http://localhost:8080/v1",
"api_key": "test-key",
}
],
}
)
)
config_path.write_text(original, encoding="utf-8")
proc = _run_migration(tmp_path)
assert proc.returncode == 0, proc.stderr
assert "Migrating config schema" not in proc.stdout
assert "can no longer be auto-migrated" in proc.stderr
assert config_path.read_text(encoding="utf-8") == original
assert not list(tmp_path.glob("*.bak-*"))
def test_docker_config_migrate_skips_unversioned_config_untouched(tmp_path: Path) -> None:
"""Unversioned configs coerce to version 0 — below the floor, so refused."""
config_path = tmp_path / "config.yaml"
original = yaml.safe_dump({"model": {"default": "m", "provider": "openrouter"}})
config_path.write_text(original, encoding="utf-8")
proc = _run_migration(tmp_path)
assert proc.returncode == 0, proc.stderr
assert "Migrating config schema" not in proc.stdout
assert "can no longer be auto-migrated" in proc.stderr
assert config_path.read_text(encoding="utf-8") == original
assert not list(tmp_path.glob("*.bak-*"))
def test_docker_config_migrate_does_not_rewrite_invalid_yaml(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
original = "model: [unterminated\n"
config_path.write_text(original, encoding="utf-8")
proc = _run_migration(tmp_path)
assert proc.returncode == 0, proc.stderr
assert "Migrating config schema" not in proc.stdout
assert "hermes config:" in proc.stderr
assert config_path.read_text(encoding="utf-8") == original
assert not list(tmp_path.glob("*.bak-*"))
def test_docker_config_migrate_skip_env_leaves_config_unchanged(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
original = yaml.safe_dump({"_config_version": 11})
config_path.write_text(original, encoding="utf-8")
proc = _run_migration(tmp_path, HERMES_SKIP_CONFIG_MIGRATION="1")
assert proc.returncode == 0, proc.stderr
assert "skipping config migration" in proc.stdout
assert config_path.read_text(encoding="utf-8") == original
assert not list(tmp_path.glob("*.bak-*"))
def test_docker_config_migrate_restores_backups_after_failed_migration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
module = _load_script_module()
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
original_config = yaml.safe_dump({"_config_version": 12, "gateway": {"provider": "telegram"}})
original_env = "TELEGRAM_BOT_TOKEN=test-token\n"
config_path.write_text(original_config, encoding="utf-8")
env_path.write_text(original_env, encoding="utf-8")
monkeypatch.setattr(module, "check_config_version", lambda: (12, DEFAULT_CONFIG["_config_version"]))
monkeypatch.setattr(module, "get_config_path", lambda: config_path)
monkeypatch.setattr(module, "get_env_path", lambda: env_path)
def _failing_migrate(*, interactive: bool, quiet: bool):
config_path.write_text("gateway: {}\n", encoding="utf-8")
env_path.write_text("", encoding="utf-8")
raise RuntimeError("boom")
monkeypatch.setattr(module, "migrate_config", _failing_migrate)
with pytest.raises(RuntimeError, match="boom"):
module.main()
assert config_path.read_text(encoding="utf-8") == original_config
assert env_path.read_text(encoding="utf-8") == original_env
assert list((tmp_path / "backups" / "config").glob("config.yaml.pre-docker-migrate.*"))
assert list((tmp_path / "backups" / "config").glob(".env.pre-docker-migrate.*"))
def test_docker_config_migrate_restores_backups_when_version_does_not_advance(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
module = _load_script_module()
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
original_config = yaml.safe_dump({"_config_version": 12, "gateway": {"provider": "telegram"}})
original_env = "TELEGRAM_BOT_TOKEN=test-token\n"
config_path.write_text(original_config, encoding="utf-8")
env_path.write_text(original_env, encoding="utf-8")
calls = iter([(12, DEFAULT_CONFIG["_config_version"]), (12, DEFAULT_CONFIG["_config_version"])])
monkeypatch.setattr(module, "check_config_version", lambda: next(calls))
monkeypatch.setattr(module, "get_config_path", lambda: config_path)
monkeypatch.setattr(module, "get_env_path", lambda: env_path)
def _non_advancing_migrate(*, interactive: bool, quiet: bool):
config_path.write_text("gateway: {}\n", encoding="utf-8")
env_path.write_text("", encoding="utf-8")
monkeypatch.setattr(module, "migrate_config", _non_advancing_migrate)
with pytest.raises(RuntimeError, match="did not advance config version"):
module.main()
assert config_path.read_text(encoding="utf-8") == original_config
assert env_path.read_text(encoding="utf-8") == original_env
def test_docker_config_migrate_second_boot_preserves_env_byte_for_byte(tmp_path: Path) -> None:
"""Regression for #51579: booting ``gateway run`` twice (i.e. a host
reboot under ``--restart unless-stopped``) must not strip or rewrite
``$HERMES_HOME/.env``. The first boot migrates the stale config and bumps
``_config_version``; the second boot must be a no-op that leaves ``.env``
byte-identical to what the user supplied.
This exercises the real script + real ``migrate_config`` + real file I/O
via subprocess — not mocks — so it covers the actual Docker boot path,
not just the failure-rollback shapes above.
"""
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 12,
"gateway": {"provider": "telegram"},
}
),
encoding="utf-8",
)
original_env = (
"TELEGRAM_BOT_TOKEN=secret-bot-token\n"
"TELEGRAM_ALLOWED_USERS=123456789\n"
"OPENROUTER_API_KEY=sk-test-provider-key\n"
)
env_path.write_text(original_env, encoding="utf-8")
env_bytes_before = env_path.read_bytes()
# ── First boot: stale config migrates, version advances. ──
first = _run_migration(tmp_path)
assert first.returncode == 0, first.stderr
assert "Migrating config schema 12 ->" in first.stdout
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
# The token (and every other credential) must survive the migration.
assert env_path.exists(), ".env must never be deleted by the boot migration"
assert env_path.read_bytes() == env_bytes_before
config_after_first = config_path.read_bytes()
first_boot_backups = sorted((tmp_path / "backups" / "config").glob("config.yaml.pre-docker-migrate.*"))
# ── Second boot (host reboot): version is current, must be a no-op. ──
second = _run_migration(tmp_path)
assert second.returncode == 0, second.stderr
assert "Migrating config schema" not in second.stdout
# .env is still present and byte-for-byte identical to the original.
assert env_path.exists()
assert env_path.read_bytes() == env_bytes_before
# config.yaml is untouched by the second boot, and no new backup is made.
assert config_path.read_bytes() == config_after_first
assert sorted((tmp_path / "backups" / "config").glob("config.yaml.pre-docker-migrate.*")) == first_boot_backups