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
RESTORE_BACKUP_FILE = ".HA_RESTORE"
RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT"
KEEP_BACKUPS = ("backups",)
KEEP_DATABASE = (
"home-assistant_v2.db",
@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
restore_database=instruction_content["restore_database"],
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
finally:
# 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:
"""Restore the backup file if any.
@ -177,7 +198,14 @@ def restore_backup(config_dir_path: str) -> bool:
restore_content=restore_content,
)
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:
backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting")

View File

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

View File

@ -19,7 +19,11 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
import aiohttp
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.core import HomeAssistant, callback
from homeassistant.helpers import (
@ -28,7 +32,7 @@ from homeassistant.helpers import (
issue_registry as ir,
)
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 .agent import (
@ -261,6 +265,14 @@ class BackupReaderWriter(abc.ABC):
) -> None:
"""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):
"""Backup reader/writer error."""
@ -318,6 +330,10 @@ class BackupManager:
self.config.load(stored["config"])
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()
@property
@ -1605,6 +1621,54 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
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:
"""Generate a backup ID."""

View File

@ -60,8 +60,10 @@ async def handle_info(
"backups": [backup.as_frontend_json() for backup in backups.values()],
"last_attempted_automatic_backup": manager.config.data.last_attempted_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_additional": manager.config.data.schedule.next_automatic_backup_additional,
"state": manager.state,
},
)

View File

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

View File

@ -85,8 +85,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -117,8 +119,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -149,8 +153,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -181,8 +187,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -213,8 +221,10 @@
]),
'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',
}),
'success': True,
'type': 'result',

View File

@ -2977,8 +2977,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3005,8 +3007,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3050,8 +3054,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3078,8 +3084,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3123,8 +3131,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3179,8 +3189,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3219,8 +3231,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3270,8 +3284,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3319,8 +3335,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3375,8 +3393,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3432,8 +3452,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3490,8 +3512,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3546,8 +3570,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3602,8 +3628,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3658,8 +3686,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -3715,8 +3745,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -4181,8 +4213,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -4226,8 +4260,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -4275,8 +4311,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -4343,8 +4381,10 @@
]),
'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',
}),
'success': True,
'type': 'result',
@ -4389,8 +4429,10 @@
]),
'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',
}),
'success': True,
'type': 'result',

View File

@ -397,8 +397,10 @@ async def test_initiate_backup(
"agent_errors": {},
"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",
}
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": {},
"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",
}
await ws_client.send_json_auto_id(
@ -724,8 +728,15 @@ async def test_initiate_backup_with_agent_error(
"agent_errors": {},
"last_attempted_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_additional": False,
"state": "idle",
}
await hass.async_block_till_done()
@ -993,8 +1004,10 @@ async def test_initiate_backup_non_agent_upload_error(
"agent_errors": {},
"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",
}
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": {},
"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",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -1217,8 +1232,10 @@ async def test_initiate_backup_file_error(
"agent_errors": {},
"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",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
@ -1703,8 +1720,10 @@ async def test_receive_backup_agent_error(
"agent_errors": {},
"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",
}
await ws_client.send_json_auto_id(
@ -1786,8 +1805,15 @@ async def test_receive_backup_agent_error(
"agent_errors": {},
"last_attempted_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_additional": False,
"state": "idle",
}
await hass.async_block_till_done()
@ -1848,8 +1874,10 @@ async def test_receive_backup_non_agent_upload_error(
"agent_errors": {},
"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",
}
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": {},
"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",
}
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": {},
"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",
}
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": {},
"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",
}
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": {},
"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",
}
for command in commands:
@ -3127,3 +3163,88 @@ async def test_initiate_backup_per_agent_encryption(
"name": "test",
"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": [],
"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",
}

View File

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

View File

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

View File

@ -332,8 +332,10 @@ async def test_agents_list_backups_error(
"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",
}

View File

@ -1,7 +1,10 @@
"""Test methods in backup_restore."""
from collections.abc import Generator
import json
from pathlib import Path
import tarfile
from typing import Any
from unittest import mock
import pytest
@ -11,6 +14,23 @@ from homeassistant import backup_restore
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(
("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 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:
@ -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 restore_result_file_content() is 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 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:
@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None:
),
):
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(
@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring(
assert 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:
@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None:
assert {
member.name for member in extractall_mock.mock_calls[-1].kwargs["members"]
} == {"data", "data/.HA_VERSION", "data/.storage", "data/www"}
assert restore_result_file_content() == {
"error": None,
"error_type": None,
"success": True,
}
@pytest.mark.parametrize(
@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore(
assert mock_unlink.call_count == unlink_calls
for call in mock_unlink.mock_calls:
assert call.args[0] == backup_file_path
assert restore_result_file_content() == {
"error": None,
"error_type": None,
"success": True,
}
@pytest.mark.parametrize(