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

@ -1449,6 +1449,40 @@ class TestQuickSnapshot:
empty.mkdir()
assert create_quick_snapshot(hermes_home=empty) is None
def test_max_file_size_skips_oversized_file(self, hermes_home, capsys):
"""Files above the cap are skipped with a warning; small files
(the pairing/cron data the snapshot exists for) still land."""
from hermes_cli.backup import create_quick_snapshot
# state.db in the fixture is a few KB — cap below it
cap = 1024
snap_id = create_quick_snapshot(
hermes_home=hermes_home, max_file_size=cap
)
assert snap_id is not None
snap_dir = hermes_home / "state-snapshots" / snap_id
assert not (snap_dir / "state.db").exists()
# Small files still captured
assert (snap_dir / "cron" / "jobs.json").exists()
with open(snap_dir / "manifest.json") as f:
meta = json.load(f)
assert "state.db" not in meta["files"]
out = capsys.readouterr().out
assert "skipping state.db" in out
assert "exceeds" in out
def test_max_file_size_none_copies_everything(self, hermes_home):
"""Default (no cap) preserves manual /snapshot behavior."""
from hermes_cli.backup import create_quick_snapshot
snap_id = create_quick_snapshot(hermes_home=hermes_home, max_file_size=None)
assert (hermes_home / "state-snapshots" / snap_id / "state.db").exists()
def test_max_file_size_under_cap_copies(self, hermes_home):
from hermes_cli.backup import create_quick_snapshot
snap_id = create_quick_snapshot(
hermes_home=hermes_home, max_file_size=1 << 30
)
assert (hermes_home / "state-snapshots" / snap_id / "state.db").exists()
def test_list_snapshots(self, hermes_home):
from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots
id1 = create_quick_snapshot(label="first", hermes_home=hermes_home)
@ -2030,7 +2064,8 @@ class TestPreUpdateBackup:
class TestRunPreUpdateBackup:
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
covers config gate, ``--no-backup`` flag, and user-facing output."""
covers the consolidated off/quick/full mode gate, CLI flags, and
user-facing output."""
@pytest.fixture
def hermes_home(self, tmp_path, monkeypatch):
@ -2047,102 +2082,145 @@ class TestRunPreUpdateBackup:
del __import__("sys").modules[mod]
return root
def test_backup_flag_creates_backup(self, hermes_home, capsys):
"""--backup forces the pre-update backup for one run even when config is off."""
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=False, backup=True))
out = capsys.readouterr().out
assert "Creating pre-update backup" in out
assert "Saved:" in out
assert "Restore:" in out
assert "hermes import" in out
assert "Disable:" in out
# Actual backup was created
backups = list((hermes_home / "backups").glob("pre-update-*.zip"))
assert len(backups) == 1
@staticmethod
def _set_mode(hermes_home, value):
import yaml
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
"_config_version": 22,
"updates": {"pre_update_backup": value},
}))
import sys as _sys
for mod in list(_sys.modules.keys()):
if mod.startswith("hermes_cli.config"):
del _sys.modules[mod]
def test_default_disabled_is_silent(self, hermes_home, capsys):
"""With the default (``pre_update_backup: false``), ``hermes update``
does NOT create a backup and stays silent zipping a large
HERMES_HOME can add minutes to every update. Users who want the
#48200 safety net opt in via the config knob or ``--backup``.
"""
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert out == ""
assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \
if (hermes_home / "backups").exists() else True
@staticmethod
def _zips(hermes_home):
d = hermes_home / "backups"
return list(d.glob("pre-update-*.zip")) if d.exists() else []
def test_no_backup_flag_skips(self, hermes_home, capsys):
@staticmethod
def _snaps(hermes_home):
d = hermes_home / "state-snapshots"
return [p for p in d.iterdir() if p.is_dir()] if d.exists() else []
def test_default_creates_quick_snapshot_only(self, hermes_home, capsys):
"""With no config, the default mode is ``quick``: a state snapshot is
created but NOT the full zip."""
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=True, backup=False))
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert "skipped (--no-backup)" in out
assert snap_id is not None
assert "Pre-update snapshot" in out
assert "Creating pre-update backup" not in out
# No backup written
assert not (hermes_home / "backups").exists() or not list(
(hermes_home / "backups").glob("pre-update-*.zip")
)
def test_config_enabled_creates_backup(self, hermes_home, capsys):
"""Users who explicitly set updates.pre_update_backup: true still get
a backup on every update this is the opt-in legacy behavior."""
import yaml
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
"_config_version": 22,
"updates": {"pre_update_backup": True},
}))
import sys as _sys
for mod in list(_sys.modules.keys()):
if mod.startswith("hermes_cli.config"):
del _sys.modules[mod]
assert self._snaps(hermes_home)
assert not self._zips(hermes_home)
def test_backup_flag_forces_full(self, hermes_home, capsys):
"""--backup forces the full zip (plus quick snapshot) for one run."""
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=True))
out = capsys.readouterr().out
assert snap_id is not None
assert "Pre-update snapshot" in out
assert "Creating pre-update backup" in out
assert "Saved:" in out
backups = list((hermes_home / "backups").glob("pre-update-*.zip"))
assert len(backups) == 1
def test_config_disabled_is_silent(self, hermes_home, capsys):
"""Explicit pre_update_backup: false behaves the same as the default —
silent no-op, no message spam."""
import yaml
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
"_config_version": 22,
"updates": {"pre_update_backup": False},
}))
# Ensure config module re-reads
import sys as _sys
for mod in list(_sys.modules.keys()):
if mod.startswith("hermes_cli.config"):
del _sys.modules[mod]
assert "hermes import" in out
assert len(self._zips(hermes_home)) == 1
def test_no_backup_flag_skips_everything(self, hermes_home, capsys):
"""--no-backup skips BOTH the quick snapshot and the zip."""
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert out == ""
assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \
if (hermes_home / "backups").exists() else True
def test_cli_flag_overrides_enabled_config(self, hermes_home, capsys):
"""--no-backup wins even when config says pre_update_backup: true."""
import yaml
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
"_config_version": 22,
"updates": {"pre_update_backup": True},
}))
import sys as _sys
for mod in list(_sys.modules.keys()):
if mod.startswith("hermes_cli.config"):
del _sys.modules[mod]
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=True, backup=False))
snap_id = _run_pre_update_backup(Namespace(no_backup=True, backup=False))
out = capsys.readouterr().out
assert snap_id is None
assert "skipped (--no-backup)" in out
assert "Pre-update snapshot" not in out
assert not self._snaps(hermes_home)
assert not self._zips(hermes_home)
def test_config_off_disables_everything_silently(self, hermes_home, capsys):
"""pre_update_backup: off — an explicit opt-out disables the quick
snapshot too (it previously ran unconditionally), with no output."""
self._set_mode(hermes_home, "off")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is None
assert out == ""
assert not self._snaps(hermes_home)
assert not self._zips(hermes_home)
def test_legacy_false_maps_to_off(self, hermes_home, capsys):
"""Legacy boolean ``false`` (the old zip opt-out) now means off."""
self._set_mode(hermes_home, False)
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
assert snap_id is None
assert capsys.readouterr().out == ""
assert not self._snaps(hermes_home)
assert not self._zips(hermes_home)
def test_legacy_true_maps_to_full(self, hermes_home, capsys):
"""Legacy boolean ``true`` (the old always-zip opt-in) means full."""
self._set_mode(hermes_home, True)
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is not None
assert "Creating pre-update backup" in out
assert "Saved:" in out
assert len(self._zips(hermes_home)) == 1
def test_config_full_mode(self, hermes_home, capsys):
self._set_mode(hermes_home, "full")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is not None
assert "Pre-update snapshot" in out
assert "Creating pre-update backup" in out
assert len(self._zips(hermes_home)) == 1
def test_config_quick_mode(self, hermes_home, capsys):
self._set_mode(hermes_home, "quick")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is not None
assert "Pre-update snapshot" in out
assert "Creating pre-update backup" not in out
assert not self._zips(hermes_home)
def test_unknown_mode_falls_back_to_quick(self, hermes_home, capsys):
self._set_mode(hermes_home, "bogus-mode")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is not None
assert "Pre-update snapshot" in out
assert not self._zips(hermes_home)
def test_no_backup_flag_overrides_full_config(self, hermes_home, capsys):
"""--no-backup wins even when config says full."""
self._set_mode(hermes_home, "full")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=True, backup=False))
out = capsys.readouterr().out
assert snap_id is None
assert "skipped (--no-backup)" in out
assert not self._snaps(hermes_home)
assert not self._zips(hermes_home)
def test_backup_flag_overrides_off_config(self, hermes_home, capsys):
"""--backup wins over config off for a single run."""
self._set_mode(hermes_home, "off")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=True))
out = capsys.readouterr().out
assert snap_id is not None
assert "Creating pre-update backup" in out
assert len(self._zips(hermes_home)) == 1
# ---------------------------------------------------------------------------