Four writers each dropped their own uniquely-named copy of config.yaml next to the real file and none of them ever deleted anything: hermes setup (config.yaml.bak.YYYYMMDD_HHMMSS, one per run even with no change), the corrupt-YAML snapshot (config.yaml.corrupt.<ts>.bak), hermes migrate xai (config.yaml.bak-pre-migrate-xai-<ts>) and the Docker boot migration (config.yaml.bak-<ts>, .env.bak-<ts>). A home dir accumulated a dozen variants with no way to tell which mattered. hermes_cli/config_backups.py::backup_config is now the single writer: backups/config/config.yaml.<reason>.<YYYYMMDD-HHMMSS>, skipped when the newest copy for that reason is byte-identical, rotated to the newest five per reason. backups/ is already excluded from full backups so nothing nests. Legacy siblings written by the old schemes are moved into the dir on first use; hand-named copies (config.yaml.bak-my-note) are left alone. Live: three `hermes setup --non-interactive` runs against an unchanged config went from three .bak files in HERMES_HOME to one pre-setup copy under backups/config/; repeated loads of broken YAML produce one corrupt copy instead of one per process (deduped by content).
81 lines
3.5 KiB
Python
81 lines
3.5 KiB
Python
"""Point-in-time copies of ``config.yaml``: one directory, one naming scheme, bounded count.
|
|
|
|
Every writer that wants a "before" copy of the user's config (setup wizard, corrupt-file
|
|
snapshot, model migrations) goes through :func:`backup_config`. Copies live in
|
|
``<HERMES_HOME>/backups/config/`` — ``backups/`` is already excluded from full backups, so they
|
|
never nest — as ``config.yaml.<reason>.<YYYYMMDD-HHMMSS>``. A copy identical to the newest one
|
|
for the same reason is skipped, and only the newest ``keep`` per reason survive, so repeated
|
|
``hermes setup`` runs or a gateway restarting against broken YAML cannot litter the home dir.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import filecmp
|
|
import logging
|
|
import shutil
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BACKUPS_SUBDIR = Path("backups") / "config"
|
|
DEFAULT_KEEP = 5
|
|
|
|
# Names earlier code wrote next to config.yaml (setup wizard, corrupt snapshot, xai migration).
|
|
# Moved into the backups dir on first use so they stop accumulating in the home root; hand-named
|
|
# copies (``config.yaml.bak-my-note``) are the user's and are never touched.
|
|
_LEGACY_SIBLING_GLOBS = ("config.yaml.bak.[0-9]*", "config.yaml.corrupt.*.bak", "config.yaml.bak-pre-migrate-*")
|
|
|
|
|
|
def backups_dir(config_path: Path) -> Path:
|
|
return config_path.parent / BACKUPS_SUBDIR
|
|
|
|
|
|
def list_config_backups(config_path: Path, reason: Optional[str] = None) -> list[Path]:
|
|
"""Existing backups, newest first; filtered to one *reason* when given."""
|
|
root = backups_dir(config_path)
|
|
if not root.is_dir():
|
|
return []
|
|
prefix = f"{config_path.name}.{reason}." if reason else f"{config_path.name}."
|
|
return sorted((p for p in root.iterdir() if p.is_file() and p.name.startswith(prefix)),
|
|
key=lambda p: p.name, reverse=True)
|
|
|
|
|
|
def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -> Optional[Path]:
|
|
"""Copy *config_path* to the backups dir; return the new path, or None when skipped/failed.
|
|
|
|
Skips when the file is missing/empty, or when the newest backup for *reason* already holds
|
|
identical bytes. Never raises: a failed backup must not block the write it precedes.
|
|
"""
|
|
try:
|
|
if not config_path.is_file() or config_path.stat().st_size == 0:
|
|
return None
|
|
root = backups_dir(config_path)
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_sweep_legacy_siblings(config_path, root)
|
|
existing = list_config_backups(config_path, reason)
|
|
if existing and filecmp.cmp(config_path, existing[0], shallow=False):
|
|
return None
|
|
dest = root / f"{config_path.name}.{reason}.{time.strftime('%Y%m%d-%H%M%S')}"
|
|
if dest.is_symlink() or dest.exists(): # never write through a planted link
|
|
return None
|
|
shutil.copy2(config_path, dest)
|
|
for stale in [dest, *existing][keep:]:
|
|
stale.unlink(missing_ok=True)
|
|
return dest
|
|
except OSError as exc:
|
|
logger.warning("Could not back up %s (%s): %s", config_path, reason, exc)
|
|
return None
|
|
|
|
|
|
def _sweep_legacy_siblings(config_path: Path, root: Path) -> None:
|
|
for pattern in _LEGACY_SIBLING_GLOBS:
|
|
for old in config_path.parent.glob(pattern):
|
|
if not old.is_file() or old.is_symlink():
|
|
continue
|
|
try:
|
|
old.replace(root / old.name)
|
|
except OSError as exc:
|
|
logger.debug("Could not move legacy backup %s: %s", old, exc)
|