Salvage of #35508 (@dchenk), rebased onto current main. Resolved the tests/tools/test_stage2_hook_puid_pgid.py conflict (kept both the envdir-creation regression test on main and the new config-migration tests). Docker image upgrades replace code under $INSTALL_DIR but preserve $HERMES_HOME on the mounted volume, so the persisted config.yaml never received the schema migrations that non-Docker `hermes update` runs (#35406). This adds scripts/docker_config_migrate.py, invoked from stage2-hook after first-boot seeding and before gateway services start: it backs up config.yaml + .env, runs migrate_config(interactive=False), and honors HERMES_SKIP_CONFIG_MIGRATION=1 for manual control. Also fixes a latent bug in check_config_version(): it called load_config() which deep-merges DEFAULT_CONFIG, so a legacy config with no raw _config_version falsely reported as already-current. It now reads the raw on-disk file so legacy configs are correctly detected for migration. Differs from #35508 as submitted (Option B cleanup): dropped the `_config_version` line added to cli-config.yaml.example and removed the accompanying test_cli_config_example_declares_latest_version change-detector test. The example is a copy-template and has no business asserting a schema version; check_config_version() reads the user's real config.yaml, not the example. This removes a second sync point that drifts on every version bump. Closes #35508. Fixes #35406. Co-authored-by: Dmitriy Cherchenko <17372886+dchenk@users.noreply.github.com>
68 lines
1.9 KiB
Python
68 lines
1.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Run Docker boot-time config migrations safely."""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from hermes_cli.config import (
|
|
check_config_version,
|
|
get_config_path,
|
|
get_env_path,
|
|
migrate_config,
|
|
)
|
|
from utils import env_var_enabled
|
|
|
|
|
|
def _backup_path(path: Path, stamp: str) -> Path:
|
|
base = path.with_name(f"{path.name}.bak-{stamp}")
|
|
if not base.exists():
|
|
return base
|
|
for index in range(1, 1000):
|
|
candidate = path.with_name(f"{path.name}.bak-{stamp}.{index}")
|
|
if not candidate.exists():
|
|
return candidate
|
|
raise RuntimeError(f"could not choose a backup path for {path}")
|
|
|
|
|
|
def _backup_existing(paths: Iterable[Path]) -> list[Path]:
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
backups: list[Path] = []
|
|
for path in paths:
|
|
if not path.is_file():
|
|
continue
|
|
dest = _backup_path(path, stamp)
|
|
shutil.copy2(path, dest)
|
|
backups.append(dest)
|
|
return backups
|
|
|
|
|
|
def main() -> int:
|
|
if env_var_enabled("HERMES_SKIP_CONFIG_MIGRATION"):
|
|
print("[config-migrate] HERMES_SKIP_CONFIG_MIGRATION is set; skipping config migration")
|
|
return 0
|
|
|
|
current_ver, latest_ver = check_config_version()
|
|
if current_ver >= latest_ver:
|
|
return 0
|
|
|
|
backups = _backup_existing((get_config_path(), get_env_path()))
|
|
backup_text = ", ".join(str(path) for path in backups) if backups else "none"
|
|
print(
|
|
f"[config-migrate] Migrating config schema {current_ver} -> {latest_ver}; "
|
|
f"backups: {backup_text}"
|
|
)
|
|
migrate_config(interactive=False, quiet=False)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as exc:
|
|
print(f"[config-migrate] ERROR: {exc}", file=sys.stderr)
|
|
raise SystemExit(1)
|