Persist backup restore status after core restart (#136838)

* Persist backup restore status after core restart

* Don't blow up if restore result file can't be removed

* Update tests
This commit is contained in:
Erik Montnemery 2025-01-29 16:58:33 +01:00 committed by GitHub
parent 8ab6bec746
commit b2ec72d75f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 356 additions and 20 deletions

View File

@ -18,6 +18,7 @@ import securetar
from .const import __version__ as HA_VERSION from .const import __version__ as HA_VERSION
RESTORE_BACKUP_FILE = ".HA_RESTORE" RESTORE_BACKUP_FILE = ".HA_RESTORE"
RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT"
KEEP_BACKUPS = ("backups",) KEEP_BACKUPS = ("backups",)
KEEP_DATABASE = ( KEEP_DATABASE = (
"home-assistant_v2.db", "home-assistant_v2.db",
@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
restore_database=instruction_content["restore_database"], restore_database=instruction_content["restore_database"],
restore_homeassistant=instruction_content["restore_homeassistant"], restore_homeassistant=instruction_content["restore_homeassistant"],
) )
except (FileNotFoundError, KeyError, json.JSONDecodeError): except FileNotFoundError:
return None
except (KeyError, json.JSONDecodeError) as err:
_write_restore_result_file(config_dir, False, err)
return None return None
finally: finally:
# Always remove the backup instruction file to prevent a boot loop # Always remove the backup instruction file to prevent a boot loop
@ -159,6 +163,23 @@ def _extract_backup(
) )
def _write_restore_result_file(
config_dir: Path, success: bool, error: Exception | None
) -> None:
"""Write the restore result file."""
result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE)
result_path.write_text(
json.dumps(
{
"success": success,
"error": str(error) if error else None,
"error_type": str(type(error).__name__) if error else None,
}
),
encoding="utf-8",
)
def restore_backup(config_dir_path: str) -> bool: def restore_backup(config_dir_path: str) -> bool:
"""Restore the backup file if any. """Restore the backup file if any.
@ -177,7 +198,14 @@ def restore_backup(config_dir_path: str) -> bool:
restore_content=restore_content, restore_content=restore_content,
) )
except FileNotFoundError as err: except FileNotFoundError as err:
raise ValueError(f"Backup file {backup_file_path} does not exist") from err file_not_found = ValueError(f"Backup file {backup_file_path} does not exist")
_write_restore_result_file(config_dir, False, file_not_found)
raise file_not_found from err
except Exception as err:
_write_restore_result_file(config_dir, False, err)
raise
else:
_write_restore_result_file(config_dir, True, None)
if restore_content.remove_after_restore: if restore_content.remove_after_restore:
backup_file_path.unlink(missing_ok=True) backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting") _LOGGER.info("Restore complete, restarting")

View File

@ -26,6 +26,7 @@ from .manager import (
BackupReaderWriterError, BackupReaderWriterError,
CoreBackupReaderWriter, CoreBackupReaderWriter,
CreateBackupEvent, CreateBackupEvent,
IdleEvent,
IncorrectPasswordError, IncorrectPasswordError,
ManagerBackup, ManagerBackup,
NewBackup, NewBackup,
@ -47,6 +48,7 @@ __all__ = [
"BackupReaderWriterError", "BackupReaderWriterError",
"CreateBackupEvent", "CreateBackupEvent",
"Folder", "Folder",
"IdleEvent",
"IncorrectPasswordError", "IncorrectPasswordError",
"LocalBackupAgent", "LocalBackupAgent",
"ManagerBackup", "ManagerBackup",

View File

@ -19,7 +19,11 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
import aiohttp import aiohttp
from securetar import SecureTarFile, atomic_contents_add from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key from homeassistant.backup_restore import (
RESTORE_BACKUP_FILE,
RESTORE_BACKUP_RESULT_FILE,
password_to_key,
)
from homeassistant.const import __version__ as HAVERSION from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import ( from homeassistant.helpers import (
@ -28,7 +32,7 @@ from homeassistant.helpers import (
issue_registry as ir, issue_registry as ir,
) )
from homeassistant.helpers.json import json_bytes from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util, json as json_util
from . import util as backup_util from . import util as backup_util
from .agent import ( from .agent import (
@ -261,6 +265,14 @@ class BackupReaderWriter(abc.ABC):
) -> None: ) -> None:
"""Restore a backup.""" """Restore a backup."""
@abc.abstractmethod
async def async_resume_restore_progress_after_restart(
self,
*,
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None:
"""Get restore events after core restart."""
class BackupReaderWriterError(BackupError): class BackupReaderWriterError(BackupError):
"""Backup reader/writer error.""" """Backup reader/writer error."""
@ -318,6 +330,10 @@ class BackupManager:
self.config.load(stored["config"]) self.config.load(stored["config"])
self.known_backups.load(stored["backups"]) self.known_backups.load(stored["backups"])
await self._reader_writer.async_resume_restore_progress_after_restart(
on_progress=self.async_on_backup_event
)
await self.load_platforms() await self.load_platforms()
@property @property
@ -1605,6 +1621,54 @@ class CoreBackupReaderWriter(BackupReaderWriter):
) )
await self._hass.services.async_call("homeassistant", "restart", blocking=True) await self._hass.services.async_call("homeassistant", "restart", blocking=True)
async def async_resume_restore_progress_after_restart(
self,
*,
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None:
"""Check restore status after core restart."""
def _read_restore_file() -> json_util.JsonObjectType | None:
"""Read the restore file."""
result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE))
try:
restore_result = json_util.json_loads_object(result_path.read_bytes())
except FileNotFoundError:
return None
finally:
try:
result_path.unlink(missing_ok=True)
except OSError as err:
LOGGER.warning(
"Unexpected error deleting backup restore result file: %s %s",
type(err),
err,
)
return restore_result
restore_result = await self._hass.async_add_executor_job(_read_restore_file)
if not restore_result:
return
success = restore_result["success"]
if not success:
LOGGER.warning(
"Backup restore failed with %s: %s",
restore_result["error_type"],
restore_result["error"],
)
state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED
on_progress(
RestoreBackupEvent(
reason=cast(str, restore_result["error"]),
stage=None,
state=state,
)
)
on_progress(IdleEvent())
def _generate_backup_id(date: str, name: str) -> str: def _generate_backup_id(date: str, name: str) -> str:
"""Generate a backup ID.""" """Generate a backup ID."""

View File

@ -60,8 +60,10 @@ async def handle_info(
"backups": [backup.as_frontend_json() for backup in backups.values()], "backups": [backup.as_frontend_json() for backup in backups.values()],
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
"last_non_idle_event": manager.last_non_idle_event,
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
"state": manager.state,
}, },
) )

View File

@ -29,6 +29,7 @@ from homeassistant.components.backup import (
BackupReaderWriterError, BackupReaderWriterError,
CreateBackupEvent, CreateBackupEvent,
Folder, Folder,
IdleEvent,
IncorrectPasswordError, IncorrectPasswordError,
NewBackup, NewBackup,
RestoreBackupEvent, RestoreBackupEvent,
@ -456,6 +457,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
finally: finally:
unsub() unsub()
async def async_resume_restore_progress_after_restart(
self,
*,
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None:
"""Check restore status after core restart."""
@callback @callback
def _async_listen_job_events( def _async_listen_job_events(
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]

View File

@ -85,8 +85,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -117,8 +119,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -149,8 +153,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -181,8 +187,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -213,8 +221,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',

View File

@ -2977,8 +2977,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3005,8 +3007,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3050,8 +3054,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3078,8 +3084,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3123,8 +3131,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3179,8 +3189,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3219,8 +3231,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3270,8 +3284,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3319,8 +3335,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3375,8 +3393,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3432,8 +3452,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3490,8 +3512,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3546,8 +3570,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3602,8 +3628,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3658,8 +3686,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -3715,8 +3745,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -4181,8 +4213,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -4226,8 +4260,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -4275,8 +4311,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -4343,8 +4381,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',
@ -4389,8 +4429,10 @@
]), ]),
'last_attempted_automatic_backup': None, 'last_attempted_automatic_backup': None,
'last_completed_automatic_backup': None, 'last_completed_automatic_backup': None,
'last_non_idle_event': None,
'next_automatic_backup': None, 'next_automatic_backup': None,
'next_automatic_backup_additional': False, 'next_automatic_backup_additional': False,
'state': 'idle',
}), }),
'success': True, 'success': True,
'type': 'result', 'type': 'result',

View File

@ -397,8 +397,10 @@ async def test_initiate_backup(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -626,8 +628,10 @@ async def test_initiate_backup_with_agent_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
@ -724,8 +728,15 @@ async def test_initiate_backup_with_agent_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": {
"manager_state": "create_backup",
"reason": "upload_failed",
"stage": None,
"state": "failed",
},
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await hass.async_block_till_done() await hass.async_block_till_done()
@ -993,8 +1004,10 @@ async def test_initiate_backup_non_agent_upload_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -1109,8 +1122,10 @@ async def test_initiate_backup_with_task_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -1217,8 +1232,10 @@ async def test_initiate_backup_file_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -1703,8 +1720,10 @@ async def test_receive_backup_agent_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
@ -1786,8 +1805,15 @@ async def test_receive_backup_agent_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": {
"manager_state": "receive_backup",
"reason": None,
"stage": None,
"state": "completed",
},
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await hass.async_block_till_done() await hass.async_block_till_done()
@ -1848,8 +1874,10 @@ async def test_receive_backup_non_agent_upload_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -1973,8 +2001,10 @@ async def test_receive_backup_file_write_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -2086,8 +2116,10 @@ async def test_receive_backup_read_tar_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -2264,8 +2296,10 @@ async def test_receive_backup_file_read_error(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -3034,8 +3068,10 @@ async def test_initiate_backup_per_agent_encryption(
"agent_errors": {}, "agent_errors": {},
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }
for command in commands: for command in commands:
@ -3127,3 +3163,88 @@ async def test_initiate_backup_per_agent_encryption(
"name": "test", "name": "test",
"with_automatic_settings": False, "with_automatic_settings": False,
} }
@pytest.mark.parametrize(
("restore_result", "last_non_idle_event"),
[
(
{"error": None, "error_type": None, "success": True},
{
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "completed",
},
),
(
{"error": "Boom!", "error_type": "ValueError", "success": False},
{
"manager_state": "restore_backup",
"reason": "Boom!",
"stage": None,
"state": "failed",
},
),
],
)
async def test_restore_progress_after_restart(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
restore_result: dict[str, Any],
last_non_idle_event: dict[str, Any],
) -> None:
"""Test restore backup progress after restart."""
with patch(
"pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode()
):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"agent_errors": {},
"backups": [],
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"last_non_idle_event": last_non_idle_event,
"next_automatic_backup": None,
"next_automatic_backup_additional": False,
"state": "idle",
}
async def test_restore_progress_after_restart_fail_to_remove(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test restore backup progress after restart when failing to remove result file."""
with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"agent_errors": {},
"backups": [],
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None,
"next_automatic_backup_additional": False,
"state": "idle",
}
assert (
"Unexpected error deleting backup restore result file: <class 'OSError'> Boom!"
in caplog.text
)

View File

@ -205,8 +205,10 @@ async def test_agents_list_backups_fail_cloud(
"backups": [], "backups": [],
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }

View File

@ -10,9 +10,12 @@
'version': '1.0.0', 'version': '1.0.0',
}), }),
]), ]),
'agent_ids': list([ 'agents': dict({
'backup.local', 'backup.local': dict({
]), 'protected': True,
'size': 0,
}),
}),
'backup_id': 'abc123', 'backup_id': 'abc123',
'database_included': True, 'database_included': True,
'date': '1970-01-01T00:00:00.000Z', 'date': '1970-01-01T00:00:00.000Z',
@ -25,16 +28,17 @@
'homeassistant_included': True, 'homeassistant_included': True,
'homeassistant_version': '2024.12.0', 'homeassistant_version': '2024.12.0',
'name': 'Test', 'name': 'Test',
'protected': False,
'size': 0,
'with_automatic_settings': True, 'with_automatic_settings': True,
}), }),
dict({ dict({
'addons': list([ 'addons': list([
]), ]),
'agent_ids': list([ 'agents': dict({
'test.remote', 'test.remote': dict({
]), 'protected': True,
'size': 0,
}),
}),
'backup_id': 'def456', 'backup_id': 'def456',
'database_included': False, 'database_included': False,
'date': '1980-01-01T00:00:00.000Z', 'date': '1980-01-01T00:00:00.000Z',
@ -47,8 +51,6 @@
'homeassistant_included': True, 'homeassistant_included': True,
'homeassistant_version': '2024.12.0', 'homeassistant_version': '2024.12.0',
'name': 'Test 2', 'name': 'Test 2',
'protected': False,
'size': 1,
'with_automatic_settings': None, 'with_automatic_settings': None,
}), }),
]), ]),

View File

@ -798,6 +798,9 @@ async def test_onboarding_backup_info(
backups = { backups = {
"abc123": backup.ManagerBackup( "abc123": backup.ManagerBackup(
addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")],
agents={
"backup.local": backup.manager.AgentBackupStatus(protected=True, size=0)
},
backup_id="abc123", backup_id="abc123",
date="1970-01-01T00:00:00.000Z", date="1970-01-01T00:00:00.000Z",
database_included=True, database_included=True,
@ -806,14 +809,14 @@ async def test_onboarding_backup_info(
homeassistant_included=True, homeassistant_included=True,
homeassistant_version="2024.12.0", homeassistant_version="2024.12.0",
name="Test", name="Test",
protected=False,
size=0,
agent_ids=["backup.local"],
failed_agent_ids=[], failed_agent_ids=[],
with_automatic_settings=True, with_automatic_settings=True,
), ),
"def456": backup.ManagerBackup( "def456": backup.ManagerBackup(
addons=[], addons=[],
agents={
"test.remote": backup.manager.AgentBackupStatus(protected=True, size=0)
},
backup_id="def456", backup_id="def456",
date="1980-01-01T00:00:00.000Z", date="1980-01-01T00:00:00.000Z",
database_included=False, database_included=False,
@ -825,9 +828,6 @@ async def test_onboarding_backup_info(
homeassistant_included=True, homeassistant_included=True,
homeassistant_version="2024.12.0", homeassistant_version="2024.12.0",
name="Test 2", name="Test 2",
protected=False,
size=1,
agent_ids=["test.remote"],
failed_agent_ids=[], failed_agent_ids=[],
with_automatic_settings=None, with_automatic_settings=None,
), ),

View File

@ -332,8 +332,10 @@ async def test_agents_list_backups_error(
"backups": [], "backups": [],
"last_attempted_automatic_backup": None, "last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None, "last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None, "next_automatic_backup": None,
"next_automatic_backup_additional": False, "next_automatic_backup_additional": False,
"state": "idle",
} }

View File

@ -1,7 +1,10 @@
"""Test methods in backup_restore.""" """Test methods in backup_restore."""
from collections.abc import Generator
import json
from pathlib import Path from pathlib import Path
import tarfile import tarfile
from typing import Any
from unittest import mock from unittest import mock
import pytest import pytest
@ -11,6 +14,23 @@ from homeassistant import backup_restore
from .common import get_test_config_dir from .common import get_test_config_dir
@pytest.fixture(autouse=True)
def remove_restore_result_file() -> Generator[None, Any, Any]:
"""Remove the restore result file."""
yield
Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True)
def restore_result_file_content() -> dict[str, Any] | None:
"""Return the content of the restore result file."""
try:
return json.loads(
Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8")
)
except FileNotFoundError:
return None
@pytest.mark.parametrize( @pytest.mark.parametrize(
("side_effect", "content", "expected"), ("side_effect", "content", "expected"),
[ [
@ -87,6 +107,11 @@ def test_restoring_backup_that_does_not_exist() -> None:
), ),
): ):
assert backup_restore.restore_backup(Path(get_test_config_dir())) is False assert backup_restore.restore_backup(Path(get_test_config_dir())) is False
assert restore_result_file_content() == {
"error": f"Backup file {backup_file_path} does not exist",
"error_type": "ValueError",
"success": False,
}
def test_restoring_backup_when_instructions_can_not_be_read() -> None: def test_restoring_backup_when_instructions_can_not_be_read() -> None:
@ -98,6 +123,7 @@ def test_restoring_backup_when_instructions_can_not_be_read() -> None:
), ),
): ):
assert backup_restore.restore_backup(Path(get_test_config_dir())) is False assert backup_restore.restore_backup(Path(get_test_config_dir())) is False
assert restore_result_file_content() is None
def test_restoring_backup_that_is_not_a_file() -> None: def test_restoring_backup_that_is_not_a_file() -> None:
@ -121,6 +147,11 @@ def test_restoring_backup_that_is_not_a_file() -> None:
), ),
): ):
assert backup_restore.restore_backup(Path(get_test_config_dir())) is False assert backup_restore.restore_backup(Path(get_test_config_dir())) is False
assert restore_result_file_content() == {
"error": f"Backup file {backup_file_path} does not exist",
"error_type": "ValueError",
"success": False,
}
def test_aborting_for_older_versions() -> None: def test_aborting_for_older_versions() -> None:
@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None:
), ),
): ):
assert backup_restore.restore_backup(config_dir) is True assert backup_restore.restore_backup(config_dir) is True
assert restore_result_file_content() == {
"error": (
"You need at least Home Assistant version 9999.99.99 to restore this backup"
),
"error_type": "ValueError",
"success": False,
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring(
assert removed_directories == { assert removed_directories == {
Path(config_dir, d) for d in expected_removed_directories Path(config_dir, d) for d in expected_removed_directories
} }
assert restore_result_file_content() == {
"error": None,
"error_type": None,
"success": True,
}
def test_extracting_the_contents_of_a_backup_file() -> None: def test_extracting_the_contents_of_a_backup_file() -> None:
@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None:
assert { assert {
member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] member.name for member in extractall_mock.mock_calls[-1].kwargs["members"]
} == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"}
assert restore_result_file_content() == {
"error": None,
"error_type": None,
"success": True,
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore(
assert mock_unlink.call_count == unlink_calls assert mock_unlink.call_count == unlink_calls
for call in mock_unlink.mock_calls: for call in mock_unlink.mock_calls:
assert call.args[0] == backup_file_path assert call.args[0] == backup_file_path
assert restore_result_file_content() == {
"error": None,
"error_type": None,
"success": True,
}
@pytest.mark.parametrize( @pytest.mark.parametrize(