fix(update): consolidate pre-update backups into one gated mechanism (#65754)

hermes update ran TWO separate pre-update backup mechanisms: the
config-gated full zip (updates.pre_update_backup, default off) and an
unconditional quick state snapshot added for #15733 that ignored the
user's setting entirely. On a large state.db (observed: 24 GB) the
'cheap' snapshot silently added ~60s to every update and ate 24 GB of
disk in state-snapshots/.

Now there is ONE mechanism, gated by updates.pre_update_backup with
three modes:

- quick (new default): state snapshot of critical small files (pairing
  JSONs, cron jobs, config, auth, per-profile DBs). Files over 1 GiB
  are skipped with a warning so a bloated state.db can never stall the
  update again.
- full: the quick snapshot plus the HERMES_HOME zip (old 'true'
  behavior; --backup forces it for one run).
- off: nothing runs — an explicit opt-out now disables the quick
  snapshot too (--no-backup does the same per-run).

Legacy booleans are honored: true -> full, false -> off.

_run_pre_update_backup() now returns the quick-snapshot id so the
post-update cron-jobs restore safety net (#34600) keeps working; the
snapshot moved from the post-fetch site to the pre-mutation site,
which also covers the zip-fallback update path it previously missed.
This commit is contained in:
Teknium 2026-07-16 08:47:25 -07:00 committed by GitHub
parent 8462764367
commit d0dcb9a5fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 343 additions and 161 deletions

View file

@ -794,18 +794,51 @@ def create_quick_snapshot(
label: Optional[str] = None,
hermes_home: Optional[Path] = None,
keep: Optional[int] = None,
max_file_size: Optional[int] = None,
) -> Optional[str]:
"""Create a quick state snapshot of critical files.
Copies STATE_FILES to a timestamped directory under state-snapshots/.
Auto-prunes old snapshots beyond the keep limit.
Args:
max_file_size: When set, individual files larger than this many bytes
are skipped (with a printed warning) instead of copied. Used by
the pre-update safety snapshot so a multi-GB ``state.db`` can
never stall ``hermes update`` or silently eat disk the small
pairing/cron/config files the snapshot exists to protect are
always captured. ``None`` (default) copies everything, which
preserves manual ``/snapshot`` and ``hermes backup --quick``
behavior.
Returns:
Snapshot ID (timestamp-based), or None if no files found.
"""
home = hermes_home or get_hermes_home()
root = _quick_snapshot_root(home)
def _too_large(path: Path, rel_name: str) -> bool:
"""True (and warn) when ``path`` exceeds the max_file_size cap."""
if max_file_size is None:
return False
try:
size = path.stat().st_size
except OSError:
return False
if size <= max_file_size:
return False
print(
f" ⚠ Snapshot: skipping {rel_name} "
f"({_format_size(size)} exceeds {_format_size(max_file_size)} limit)"
)
logger.warning(
"Quick snapshot skipped %s: %d bytes exceeds %d byte limit",
rel_name,
size,
max_file_size,
)
return True
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
snap_id = f"{ts}-{label}" if label else ts
snap_dir = root / snap_id
@ -831,6 +864,8 @@ def create_quick_snapshot(
# the board databases + their metadata to restore a board.
if "/workspaces/" in f"/{sub_rel}/" or "/attachments/" in f"/{sub_rel}/":
continue
if _too_large(sub, sub_rel):
continue
dst = snap_dir / sub_rel
dst.parent.mkdir(parents=True, exist_ok=True)
try:
@ -850,6 +885,9 @@ def create_quick_snapshot(
if not src.is_file():
continue
if _too_large(src, rel):
continue
dst = snap_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
@ -1239,7 +1277,7 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
than no backup at all (and the wrapper in ``main.py`` would still print
a misleading ``Saved: <path>`` line for a file that no longer exists).
Operators who genuinely don't want a backup should set
``updates.pre_update_backup: false`` in config that gates creation.
``updates.pre_update_backup: off`` in config that gates creation.
"""
keep = max(keep, 1)
if not backup_dir.exists():