Don't allow creating backups if Home Assistant is not running (#139499)

* Don't allow creating backups if hass is not running

* Revert "Don't allow creating backups if hass is not running"

This reverts commit 1bf545eb25f20fc27fe161691a94531cba7e005c.

* Set backup manager to idle only after Home Assistant has started

* Update according to discussion, add tests

* Add more test
This commit is contained in:
Erik Montnemery 2025-03-10 14:40:08 +01:00 committed by GitHub
parent 76e76a417c
commit 219b441be0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 85 additions and 5 deletions

View File

@ -120,6 +120,7 @@ class BackupManagerState(StrEnum):
IDLE = "idle" IDLE = "idle"
CREATE_BACKUP = "create_backup" CREATE_BACKUP = "create_backup"
BLOCKED = "blocked"
RECEIVE_BACKUP = "receive_backup" RECEIVE_BACKUP = "receive_backup"
RESTORE_BACKUP = "restore_backup" RESTORE_BACKUP = "restore_backup"
@ -228,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent):
state: RestoreBackupState state: RestoreBackupState
@dataclass(frozen=True, kw_only=True, slots=True)
class BlockedEvent(ManagerStateEvent):
"""Backup manager blocked, Home Assistant is starting."""
manager_state: BackupManagerState = BackupManagerState.BLOCKED
class BackupPlatformProtocol(Protocol): class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have.""" """Define the format that backup platforms can have."""
@ -342,7 +350,7 @@ class BackupManager:
self.remove_next_delete_event: Callable[[], None] | None = None self.remove_next_delete_event: Callable[[], None] | None = None
# Latest backup event and backup event subscribers # Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = IdleEvent() self.last_event: ManagerStateEvent = BlockedEvent()
self.last_non_idle_event: ManagerStateEvent | None = None self.last_non_idle_event: ManagerStateEvent | None = None
self._backup_event_subscriptions = hass.data[ self._backup_event_subscriptions = hass.data[
DATA_BACKUP DATA_BACKUP
@ -356,10 +364,19 @@ class BackupManager:
self.known_backups.load(stored["backups"]) self.known_backups.load(stored["backups"])
await self._reader_writer.async_validate_config(config=self.config) await self._reader_writer.async_validate_config(config=self.config)
await self._reader_writer.async_resume_restore_progress_after_restart( await self._reader_writer.async_resume_restore_progress_after_restart(
on_progress=self.async_on_backup_event on_progress=self.async_on_backup_event
) )
async def set_manager_idle_after_start(hass: HomeAssistant) -> None:
"""Set manager to idle after start."""
self.async_on_backup_event(IdleEvent())
if self.state == BackupManagerState.BLOCKED:
# If we're not finishing a restore job, set the manager to idle after start
start.async_at_started(self.hass, set_manager_idle_after_start)
await self.load_platforms() await self.load_platforms()
@property @property
@ -1319,7 +1336,7 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state): if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state) LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event self.last_event = event
if not isinstance(event, IdleEvent): if not isinstance(event, (BlockedEvent, IdleEvent)):
self.last_non_idle_event = event self.last_non_idle_event = event
for subscription in self._backup_event_subscriptions: for subscription in self._backup_event_subscriptions:
subscription(event) subscription(event)

View File

@ -47,7 +47,8 @@ from homeassistant.components.backup.manager import (
WrittenBackup, WrittenBackup,
) )
from homeassistant.components.backup.util import password_to_key from homeassistant.components.backup.util import password_to_key
from homeassistant.core import HomeAssistant from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir from homeassistant.helpers import issue_registry as ir
@ -3469,3 +3470,66 @@ async def test_restore_progress_after_restart_fail_to_remove(
"Unexpected error deleting backup restore result file: <class 'OSError'> Boom!" "Unexpected error deleting backup restore result file: <class 'OSError'> Boom!"
in caplog.text in caplog.text
) )
async def test_manager_blocked_until_home_assistant_started(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test backup manager's state is blocked until Home Assistant has started."""
hass.set_state(CoreState.not_running)
await setup_backup_integration(hass)
manager = hass.data[DATA_MANAGER]
assert manager.state == BackupManagerState.BLOCKED
assert manager.last_non_idle_event is None
# Fired when Home Assistant changes to starting state
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
await hass.async_block_till_done()
assert manager.state == BackupManagerState.BLOCKED
assert manager.last_non_idle_event is None
# Fired when Home Assistant changes to running state
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert manager.state == BackupManagerState.IDLE
assert manager.last_non_idle_event is None
async def test_manager_not_blocked_after_restore(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test restore backup progress after restart."""
restore_result = {"error": None, "error_type": None, "success": True}
hass.set_state(CoreState.not_running)
with patch(
"pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode()
):
await setup_backup_integration(hass)
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": {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "completed",
},
"next_automatic_backup": None,
"next_automatic_backup_additional": False,
"state": "idle",
}

View File

@ -11,7 +11,7 @@ import pytest
from homeassistant.auth.models import RefreshToken from homeassistant.auth.models import RefreshToken
from homeassistant.components.hassio.handler import HassIO, HassioAPIError from homeassistant.components.hassio.handler import HassIO, HassioAPIError
from homeassistant.core import CoreState, HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -75,7 +75,6 @@ def hassio_stubs(
"homeassistant.components.hassio.issues.SupervisorIssues.setup", "homeassistant.components.hassio.issues.SupervisorIssues.setup",
), ),
): ):
hass.set_state(CoreState.starting)
hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {}))
return hass_api.call_args[0][1] return hass_api.call_args[0][1]