Skip to content

Commit d21fae6

Browse files
Patch76claude
andauthored
fix: await pre-restore safety backup and forward password on snapshot restore (#1684)
* fix: await pre-restore safety backup and forward password on snapshot restore ha_manage_backup(scope=snapshot, action=restore) created a pre-restore safety backup via backup/generate and issued backup/restore back-to-back without waiting. HA's backup/generate returns once the job is initiated, not finished, and the backup manager rejects any new operation while a backup runs ("Backup manager busy: create_backup"), so the restore collided with the safety backup the same call had just started – a self-induced deadlock. Each retry spawned another safety backup and failed the same way; the restore never ran. Await the safety backup through the existing _poll_backup_completion helper (the same completion poll create_backup already uses) before issuing backup/restore. Also forward the already-fetched default backup password into restore_params so protected (encrypted) backups decrypt on restore; HA's backup/restore schema types password as str, so it is only included when a default password is available. Adds regression tests pinning the await-before-restore ordering and the password forwarding/omission. Closes #1681 * test: stub safety-backup poll in restore warnings-shape test The await added to _create_safety_backup makes restore_backup poll backup/info for safety-backup completion. test_backup_restore_warnings_shape scripted send_command as a fixed list without that poll, so the list ran dry (StopAsyncIteration) in CI. Patch _poll_backup_completion so the test stays focused on its warnings-list shape contract; the poll itself is covered by test_backup_restore. * fix: reconcile restore params with target backup and size safety-backup poll Address the four items from the PR #1684 maintainer review. All four key off the target backup's own backup/info entry, which the restore path previously ignored (it only checked existence), so capture the matched entry once and derive from it. - Password is now forwarded only for a protected target. `password` is HA's default create_backup.password, independent of whether the target backup is encrypted; HA validates it against the target unconditionally and rejects a password on an unprotected backup ("Invalid password for backup" -> IncorrectPasswordError). Gate on the target's `protected` flag so an unprotected snapshot still restores on a default-password instance. - restore_database is reconciled against the target's `database_included`. When restoring Home Assistant, Supervisor requires the two to match and otherwise raises "Restore database must match backup"; the value is derived from the target (falling back to the caller's request only when the field is absent) and an override is surfaced as a warning. - The safety backup polls with a dedicated _SAFETY_BACKUP_MAX_WAIT_S (1800s) instead of the 300s fast-backup constant. The safety backup is a full backup (include_database, all add-ons on Supervised); a multi-GB full backup on constrained hardware can exceed the fast-backup window, time out, abort the restore, and trigger a retry that spawns another full backup (#1681). - The safety-backup completion poll's late-completion warnings are returned to the caller and folded into the restore response's top-level warnings list, instead of being discarded. Expands the #1681 restore regression suite to cover the unprotected-target, DB-included-target, and late-warning paths. * fix: read target protected per-agent and gate db reconcile to Supervised Round-3 review fixes for the snapshot-restore reconciliation (#1681). 1. Password gate read a non-existent top-level `protected`. HA's `backup/info` returns ManagerBackup entries where `protected` is a per-agent field (AgentBackupStatus, under `agents[<agent_id>]`), not top-level – only `database_included` is inherited top-level from BaseBackup. So `matched.get("protected")` was always None against real HA and the password was never forwarded, re-breaking decryption of protected backups (the exact #1681 case). A backup is encrypted as a whole, so a new `_backup_protected` helper derives the flag from the agents map (any agent reporting protected), shared by both the restore gate and `_summarize_backup` so the two reads can't diverge and a non-dict agents map can't raise. `_summarize_backup` had the same latent top-level assumption and is fixed by the same helper. 2. `restore_database` reconciliation ran unconditionally, but the "Restore database must match backup" constraint is Supervisor-only (SupervisorBackupReaderWriter). HA Core's CoreBackupReaderWriter writes the caller's value verbatim, so overriding it on Core silently discarded an explicit request for no HA-side reason. Gate the override on `local_agent == "hassio.local"`; narrow the comment, warning, and docstring to name Supervisor as the source of the requirement. 3. Tests now match HA's real `backup/info` shape (`protected` nested under `agents`), so the finding-1 regression is visible to CI. Added: direct `_backup_protected` coverage, an any-agent-protected restore case, a Core-honours-caller-value case for `restore_database`, safety-backup assertions in the unprotected-target test, and a failed-restore path asserting ToolError with backup_id context. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 90e6229 commit d21fae6

4 files changed

Lines changed: 686 additions & 39 deletions

File tree

src/ha_mcp/tools/backup.py

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
# happy-path wait noticeable.
5353
_BACKUP_MAX_WAIT_S = 300
5454
_BACKUP_POLL_INTERVAL_S = 2
55+
# The pre-restore safety backup is a *full* backup (include_database=True, plus
56+
# all add-ons on Supervised), unlike the fast create_backup path. A multi-GB
57+
# full backup on constrained hardware (~2.1 GB on a Pi 5, #1681) can run well
58+
# past the fast-backup window, so its completion poll gets a larger budget —
59+
# timing out here aborts the restore and the retry spawns another full backup.
60+
_SAFETY_BACKUP_MAX_WAIT_S = 1800
5561
# Clock-skew tolerance when filtering backup entries by date vs job-start.
5662
_BACKUP_DATE_FILTER_TOLERANCE_S = 5
5763

@@ -540,17 +546,23 @@ async def _create_safety_backup(
540546
ws_client: HomeAssistantWebSocketClient,
541547
password: str | None,
542548
agent_id: str,
543-
) -> str | None:
544-
"""Create a pre-restore safety backup.
549+
) -> tuple[str | None, list[str]]:
550+
"""Create a pre-restore safety backup and wait for it to complete.
545551
546552
``agent_id`` is the local backup agent (Supervisor's ``hassio.local`` or
547553
Core's ``backup.local``) discovered by the caller.
548554
549-
Returns the safety backup ID, or None when password is None (backup intentionally
550-
skipped). Raises ToolError if backup creation fails.
555+
Blocks until the safety backup finishes. HA's ``backup/generate`` returns
556+
on job initiation, not completion, so waiting here lets the caller issue
557+
``backup/restore`` afterwards without colliding with a still-running
558+
backup. Returns ``(job_id, warnings)`` — ``job_id`` is None when password
559+
is None (backup intentionally skipped); ``warnings`` carries any late-
560+
completion notice from the poll so the caller can surface it before the
561+
destructive restore. Raises ToolError if the backup fails to start or does
562+
not complete in time.
551563
"""
552564
if password is None:
553-
return None
565+
return None, []
554566

555567
now = datetime.now()
556568
safety_backup_name = f"PreRestore_Safety_{now.strftime('%Y-%m-%d_%H:%M:%S')}"
@@ -579,8 +591,45 @@ async def _create_safety_backup(
579591
)
580592

581593
safety_backup_id = safety_backup.get("result", {}).get("backup_job_id")
582-
logger.info(f"Safety backup created: {safety_backup_id}")
583-
return cast(str, safety_backup_id)
594+
logger.info(f"Safety backup started: {safety_backup_id}, waiting for completion...")
595+
596+
# Wait for the safety backup to finish before returning. HA's
597+
# backup/generate WS command returns as soon as the job is *initiated*,
598+
# not when it completes, and the backup manager rejects any new operation
599+
# while a backup is running ("Backup manager busy: create_backup"). If the
600+
# caller issued backup/restore right after this returned, it would collide
601+
# with the safety backup this same call just started – a self-induced
602+
# deadlock (#1681). Reuse the same completion poll create_backup already
603+
# uses rather than adding a second wait mechanism.
604+
poll_result = await _poll_backup_completion(
605+
ws_client,
606+
safety_backup_name,
607+
cast(str, safety_backup_id),
608+
max_wait_seconds=_SAFETY_BACKUP_MAX_WAIT_S,
609+
poll_interval=_BACKUP_POLL_INTERVAL_S,
610+
agent_id=agent_id,
611+
)
612+
logger.info(f"Safety backup completed: {safety_backup_id}")
613+
return cast(str, safety_backup_id), poll_result.get("warnings", [])
614+
615+
616+
def _backup_protected(entry: dict[str, Any]) -> bool | None:
617+
"""Whether a ``backup/info`` entry is encrypted.
618+
619+
``protected`` is a per-agent field (AgentBackupStatus), not top-level. A
620+
backup is encrypted as a whole, so any agent reporting it makes the backup
621+
protected; returns None when the ``agents`` map is absent/malformed or no
622+
agent reports the flag (unknown rather than "unprotected").
623+
"""
624+
agents = entry.get("agents")
625+
if not isinstance(agents, dict):
626+
return None
627+
flags = [
628+
a.get("protected")
629+
for a in agents.values()
630+
if isinstance(a, dict) and a.get("protected") is not None
631+
]
632+
return any(flags) if flags else None
584633

585634

586635
async def restore_backup(
@@ -594,7 +643,12 @@ async def restore_backup(
594643
Args:
595644
client: Home Assistant REST client
596645
backup_id: Backup ID to restore
597-
restore_database: Whether to restore database (historical data)
646+
restore_database: Whether to restore database (historical data).
647+
On Supervised installs this is reconciled against the target
648+
backup's ``database_included`` flag, which Supervisor requires to
649+
match when restoring Home Assistant; a warning is returned if the
650+
value is overridden. On Core installs the caller's value is honoured
651+
as-is (Core enforces no such match).
598652
599653
Returns:
600654
Dictionary with restore result including safety_backup_id, status, etc.
@@ -627,9 +681,9 @@ async def restore_backup(
627681
)
628682

629683
backups = backup_info.get("result", {}).get("backups", [])
630-
backup_exists = any(b.get("backup_id") == backup_id for b in backups)
684+
matched = next((b for b in backups if b.get("backup_id") == backup_id), None)
631685

632-
if not backup_exists:
686+
if matched is None:
633687
raise_tool_error(
634688
create_error_response(
635689
ErrorCode.RESOURCE_NOT_FOUND,
@@ -655,17 +709,57 @@ async def restore_backup(
655709
logger.warning("No default password - proceeding without safety backup")
656710
password = None
657711

658-
safety_backup_id = await _create_safety_backup(ws_client, password, local_agent)
712+
safety_backup_id, safety_warnings = await _create_safety_backup(
713+
ws_client, password, local_agent
714+
)
715+
716+
# `backup/info` returns ManagerBackup entries: `database_included` is a
717+
# top-level field (inherited from BaseBackup), but `protected` is NOT —
718+
# it lives per-agent under the entry's `agents` map (AgentBackupStatus),
719+
# so a top-level `matched.get("protected")` is always None against real
720+
# HA. Derive it from the agents map (shared with _summarize_backup).
721+
target_protected = _backup_protected(matched)
722+
723+
# Reconcile restore_database with the target only on Supervised. HA's
724+
# Supervisor raises "Restore database must match backup" when
725+
# restore_homeassistant is set and restore_database != the backup's
726+
# database_included flag (hassio/backup.py). HA Core's restore path
727+
# (CoreBackupReaderWriter) has no such constraint and writes the caller's
728+
# value verbatim, so overriding it there would silently discard an
729+
# explicit request for no HA-side reason. Fall back to the caller's
730+
# request when not Supervised or when the field is absent. Surface a
731+
# warning when the derived value overrides what the caller asked for so
732+
# the override isn't silent on a destructive op.
733+
is_supervised = local_agent == "hassio.local"
734+
target_database_included = matched.get("database_included")
735+
if is_supervised and target_database_included is not None:
736+
effective_restore_database = target_database_included
737+
else:
738+
effective_restore_database = restore_database
659739

660740
# Perform restore
661-
restore_params = {
741+
restore_params: dict[str, Any] = {
662742
"backup_id": backup_id,
663743
"agent_id": local_agent,
664-
"restore_database": restore_database,
744+
"restore_database": effective_restore_database,
665745
"restore_homeassistant": True,
666746
"restore_addons": [], # Restore all addons from backup
667747
"restore_folders": [], # Restore all folders from backup
668748
}
749+
# Forward the default backup password ONLY for a protected (encrypted)
750+
# target. `password` here is HA's default create_backup.password, which
751+
# is independent of whether *this* backup is encrypted: HA validates the
752+
# password against the target unconditionally and rejects a password on
753+
# an unprotected backup ("Invalid password for backup" →
754+
# IncorrectPasswordError). Gate on the target's per-agent `protected`
755+
# flag (read above) so an unprotected snapshot still restores on a
756+
# default-password instance. HA's backup/restore schema types `password`
757+
# as `str` (not `str | None`), so only include the key when we actually
758+
# forward one — passing None would fail voluptuous validation. Without
759+
# this a protected backup cannot be restored even though the HA UI,
760+
# which applies the stored key, succeeds (#1681).
761+
if password is not None and target_protected:
762+
restore_params["password"] = password
669763

670764
result = await ws_client.send_command("backup/restore", **restore_params)
671765

@@ -678,6 +772,18 @@ async def restore_backup(
678772
warnings = [
679773
"Home Assistant is restarting. Connection will be temporarily lost."
680774
]
775+
# Surface any late-completion notice from the safety-backup poll —
776+
# a slow backup subsystem right before a destructive restore is
777+
# exactly the signal a caller wants to see.
778+
warnings.extend(safety_warnings)
779+
if effective_restore_database != restore_database:
780+
warnings.append(
781+
"restore_database was adjusted to "
782+
f"{effective_restore_database} to match the target backup "
783+
"(Home Assistant's Supervisor requires it to match the "
784+
"backup's database_included flag when restoring Home "
785+
"Assistant)."
786+
)
681787
if safety_backup_id is None:
682788
warnings.append(
683789
"No safety backup was created (the default backup "
@@ -697,7 +803,7 @@ async def restore_backup(
697803
"backup_id": backup_id,
698804
"status": "Restore initiated - Home Assistant will restart",
699805
"safety_backup_id": safety_backup_id,
700-
"restore_database": restore_database,
806+
"restore_database": effective_restore_database,
701807
"warnings": warnings,
702808
"note": note,
703809
}
@@ -761,7 +867,8 @@ def _summarize_backup(entry: dict[str, Any]) -> dict[str, Any]:
761867
"name": entry.get("name"),
762868
"date": entry.get("date"),
763869
"size_bytes": size_bytes,
764-
"protected": entry.get("protected"),
870+
# per-agent field, derived from the agents map (see _backup_protected)
871+
"protected": _backup_protected(entry),
765872
"database_included": entry.get("database_included"),
766873
"homeassistant_included": entry.get("homeassistant_included"),
767874
"homeassistant_version": entry.get("homeassistant_version"),

tests/src/unit/test_backup_agent_lookup.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,41 @@
1111
from fastmcp.exceptions import ToolError
1212

1313
from ha_mcp.tools.backup import (
14+
_backup_protected,
1415
_get_local_backup_agent_id,
1516
_summarize_backup,
1617
list_backups,
1718
restore_backup,
1819
)
1920

2021

22+
class TestBackupProtected:
23+
"""`_backup_protected` derives the encrypted flag from the per-agent map."""
24+
25+
def test_any_agent_protected_makes_it_protected(self):
26+
entry = {
27+
"agents": {
28+
"backup.local": {"protected": False},
29+
"google_drive.cloud": {"protected": True},
30+
}
31+
}
32+
assert _backup_protected(entry) is True
33+
34+
def test_all_agents_unprotected(self):
35+
entry = {"agents": {"backup.local": {"protected": False}}}
36+
assert _backup_protected(entry) is False
37+
38+
def test_missing_agents_is_unknown(self):
39+
assert _backup_protected({}) is None
40+
41+
def test_no_agent_reports_protected_is_unknown(self):
42+
# agents present but none carry the field → unknown, not "unprotected".
43+
assert _backup_protected({"agents": {"backup.local": {"size": 1}}}) is None
44+
45+
def test_non_dict_agents_is_unknown(self):
46+
assert _backup_protected({"agents": ["unexpected"]}) is None
47+
48+
2149
def _ws_client(agents_payload: dict) -> AsyncMock:
2250
"""Build a mock WS client whose `send_command("backup/agents/info")` returns the given payload."""
2351
ws = AsyncMock()
@@ -157,15 +185,6 @@ async def test_success_returns_top_level_warnings_list(self):
157185
},
158186
# _create_safety_backup → backup/generate
159187
{"success": True, "result": {"backup_job_id": "safety_job_1"}},
160-
# _create_safety_backup polling → backup/info loop
161-
{
162-
"success": True,
163-
"result": {
164-
"backups": [
165-
{"name": "Pre_Restore_Safety", "backup_id": "safety_xyz"}
166-
]
167-
},
168-
},
169188
# backup/restore — the actual restore call
170189
{"success": True},
171190
]
@@ -175,9 +194,17 @@ async def test_success_returns_top_level_warnings_list(self):
175194
client.token = "token"
176195
client.verify_ssl = False
177196

178-
with patch(
179-
"ha_mcp.tools.backup.get_connected_ws_client",
180-
new=AsyncMock(return_value=(ws, None)),
197+
# The safety-backup completion poll is exercised by test_backup_restore;
198+
# stub it here so this test stays focused on the warnings contract.
199+
with (
200+
patch(
201+
"ha_mcp.tools.backup.get_connected_ws_client",
202+
new=AsyncMock(return_value=(ws, None)),
203+
),
204+
patch(
205+
"ha_mcp.tools.backup._poll_backup_completion",
206+
new=AsyncMock(return_value={"success": True}),
207+
),
181208
):
182209
result = await restore_backup(client, "abc123")
183210

@@ -200,14 +227,14 @@ def test_projects_core_fields_and_largest_agent_size(self):
200227
"backup_id": "abc",
201228
"name": "Nightly",
202229
"date": "2026-06-14T02:00:00+00:00",
203-
"protected": True,
204230
"database_included": False,
205231
"homeassistant_included": True,
206232
"homeassistant_version": "2026.6.0",
207233
"with_automatic_settings": True,
234+
# `protected` is a per-agent field (AgentBackupStatus), not top-level.
208235
"agents": {
209-
"backup.local": {"size": 100},
210-
"google_drive.cloud": {"size": 250},
236+
"backup.local": {"size": 100, "protected": True},
237+
"google_drive.cloud": {"size": 250, "protected": True},
211238
},
212239
}
213240
out = _summarize_backup(entry)

0 commit comments

Comments
 (0)