fix(backup): hermes import never overwrites volatile gateway runtime state (NS-501) (#48243)
Importing a backup wrote every file from the zip over the target home wholesale. On a hosted instance this clobbered gateway_state.json with the source machine's last recorded run/desired state — driving the container-boot reconciler (container_boot._read_desired_state, which only auto-starts a gateway whose state is "running") off stale/foreign state and leaving the gateway stuck "starting", disconnected from the Nous portal. Add _IMPORT_SKIP_NAMES (gateway_state.json, gateway.pid, cron.pid, gateway.lock, processes.json) and skip them by basename in run_import, so both the root profile and named profiles preserve the target's own runtime state. This mirrors what container_boot._STALE_RUNTIME_FILES already sweeps on every container boot, and protects against older backups that predate the backup-side exclusions. The import summary reports which files were preserved. This is the second half of NS-501 (filed separately as NS-508): the upload 502 was fixed in #47663; this fixes the import-breaks-the-instance half.
This commit is contained in:
parent
0ddd21c74e
commit
9c3c5da356
2 changed files with 174 additions and 0 deletions
|
|
@ -64,6 +64,39 @@ _EXCLUDED_NAMES = {
|
|||
"cron.pid",
|
||||
}
|
||||
|
||||
# File names that ``hermes import`` must never overwrite, matched by basename so
|
||||
# they're caught for the root profile (``gateway_state.json``) and for named
|
||||
# profiles alike (``profiles/<name>/gateway_state.json``).
|
||||
#
|
||||
# These hold *volatile gateway/process runtime state that is namespaced to the
|
||||
# machine or container the backup was taken on* — PIDs in a dead process
|
||||
# namespace, a runtime lock, the process registry, and the gateway's last
|
||||
# recorded run/desired state. Restoring them onto a different host (or a hosted
|
||||
# container) is at best meaningless and at worst actively harmful:
|
||||
#
|
||||
# - ``gateway_state.json`` drives the container-boot reconciler
|
||||
# (``container_boot._read_desired_state``), which only auto-starts a
|
||||
# gateway whose recorded state is ``running``. A backup taken from a
|
||||
# machine where the gateway was stopped (or carrying a stale/foreign
|
||||
# value) overwrites the container's own state and leaves the gateway
|
||||
# stuck "starting"/"cooking", disconnecting it from the Nous portal
|
||||
# (NS-508 / the second half of NS-501).
|
||||
# - ``gateway.pid`` / ``cron.pid`` / ``gateway.lock`` / ``processes.json``
|
||||
# reference PIDs and locks in the *source* machine's process namespace; a
|
||||
# numerically-equal PID in the new environment is a different process.
|
||||
# These mirror exactly what ``container_boot._STALE_RUNTIME_FILES`` already
|
||||
# sweeps on every container boot.
|
||||
#
|
||||
# Older backups predate the backup-side exclusions, so we filter on import too
|
||||
# rather than trusting the archive's contents.
|
||||
_IMPORT_SKIP_NAMES = {
|
||||
"gateway_state.json",
|
||||
"gateway.pid",
|
||||
"cron.pid",
|
||||
"gateway.lock",
|
||||
"processes.json",
|
||||
}
|
||||
|
||||
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
|
||||
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
|
||||
|
||||
|
|
@ -385,6 +418,7 @@ def run_import(args) -> None:
|
|||
|
||||
errors = []
|
||||
restored = 0
|
||||
skipped_runtime: list[str] = []
|
||||
t0 = time.monotonic()
|
||||
|
||||
for member in members:
|
||||
|
|
@ -397,6 +431,16 @@ def run_import(args) -> None:
|
|||
if not rel:
|
||||
continue
|
||||
|
||||
# Never overwrite volatile gateway/process runtime state. These are
|
||||
# namespaced to the machine/container the backup was taken on;
|
||||
# clobbering them (especially gateway_state.json) breaks the gateway
|
||||
# reconciler on the target and disconnects hosted instances from the
|
||||
# Nous portal. Matched by basename so both the root profile and
|
||||
# named profiles (profiles/<name>/gateway_state.json) are covered.
|
||||
if Path(rel).name in _IMPORT_SKIP_NAMES:
|
||||
skipped_runtime.append(rel)
|
||||
continue
|
||||
|
||||
target = hermes_root / rel
|
||||
|
||||
# Security: reject absolute paths and traversals
|
||||
|
|
@ -433,6 +477,16 @@ def run_import(args) -> None:
|
|||
if len(errors) > 10:
|
||||
print(f" ... and {len(errors) - 10} more")
|
||||
|
||||
if skipped_runtime:
|
||||
print(
|
||||
f"\n Preserved {len(skipped_runtime)} runtime state "
|
||||
f"file(s) (kept this machine's, not the backup's):"
|
||||
)
|
||||
for rel in sorted(skipped_runtime)[:10]:
|
||||
print(f" {rel}")
|
||||
if len(skipped_runtime) > 10:
|
||||
print(f" ... and {len(skipped_runtime) - 10} more")
|
||||
|
||||
# Post-import: restore profile wrapper scripts
|
||||
profiles_dir = hermes_root / "profiles"
|
||||
restored_profiles = []
|
||||
|
|
|
|||
|
|
@ -543,6 +543,126 @@ class TestImport:
|
|||
# traversal file should NOT exist outside hermes home
|
||||
assert not (tmp_path / "etc" / "passwd").exists()
|
||||
|
||||
def test_preserves_live_gateway_state(self, tmp_path, monkeypatch):
|
||||
"""Import must not overwrite the target's gateway_state.json.
|
||||
|
||||
The backup carries the *source* machine's gateway run/desired state.
|
||||
Restoring it onto a hosted container drives the boot reconciler off
|
||||
stale/foreign state and leaves the gateway stuck "starting",
|
||||
disconnecting it from the Nous portal (NS-508). The live file wins.
|
||||
"""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# The target (e.g. hosted container) already has its own live state.
|
||||
live_state = '{"gateway_state": "running", "desired_state": "running"}'
|
||||
(hermes_home / "gateway_state.json").write_text(live_state)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
# A backup from a laptop where the gateway was stopped.
|
||||
"gateway_state.json": '{"gateway_state": "stopped", "desired_state": "stopped"}',
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
# config.yaml is restored normally...
|
||||
assert (hermes_home / "config.yaml").read_text() == "model: test\n"
|
||||
# ...but the live gateway_state.json is untouched.
|
||||
assert (hermes_home / "gateway_state.json").read_text() == live_state
|
||||
|
||||
def test_does_not_seed_gateway_state_when_absent(self, tmp_path, monkeypatch):
|
||||
"""A backup's gateway_state.json is dropped, not written, when the
|
||||
target has none — a foreign state must never seed the reconciler."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"gateway_state.json": '{"gateway_state": "stopped"}',
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
assert not (hermes_home / "gateway_state.json").exists()
|
||||
|
||||
def test_preserves_per_profile_gateway_state(self, tmp_path, monkeypatch):
|
||||
"""The skip is matched by basename, so a named profile's
|
||||
gateway_state.json (profiles/<name>/gateway_state.json) is preserved
|
||||
the same way the root profile's is."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "profiles" / "coder").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
live_state = '{"gateway_state": "running"}'
|
||||
(hermes_home / "profiles" / "coder" / "gateway_state.json").write_text(live_state)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"profiles/coder/config.yaml": "model: anthropic\n",
|
||||
"profiles/coder/gateway_state.json": '{"gateway_state": "stopped"}',
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
# Profile config is restored, but its live gateway state is preserved.
|
||||
assert (hermes_home / "profiles" / "coder" / "config.yaml").read_text() == "model: anthropic\n"
|
||||
assert (
|
||||
hermes_home / "profiles" / "coder" / "gateway_state.json"
|
||||
).read_text() == live_state
|
||||
|
||||
def test_preserves_runtime_pid_and_process_files(self, tmp_path, monkeypatch):
|
||||
"""gateway.pid / cron.pid / gateway.lock / processes.json from a backup
|
||||
reference the source machine's process namespace and must never be
|
||||
written over the target's."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Live runtime files belonging to the target's own processes.
|
||||
(hermes_home / "gateway.pid").write_text("4242")
|
||||
(hermes_home / "processes.json").write_text('{"live": true}')
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"gateway.pid": "9999",
|
||||
"cron.pid": "8888",
|
||||
"gateway.lock": "7777",
|
||||
"processes.json": '{"stale": true}',
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
# Live runtime files are untouched; the backup's foreign ones never land.
|
||||
assert (hermes_home / "gateway.pid").read_text() == "4242"
|
||||
assert (hermes_home / "processes.json").read_text() == '{"live": true}'
|
||||
# cron.pid / gateway.lock had no live copy and were not seeded.
|
||||
assert not (hermes_home / "cron.pid").exists()
|
||||
assert not (hermes_home / "gateway.lock").exists()
|
||||
|
||||
def test_confirmation_prompt_abort(self, tmp_path, monkeypatch):
|
||||
"""Import aborts when user says no to confirmation."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue