diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 09c07d1b364..7cec74c9dce 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -115,7 +115,6 @@ DEFAULT_INTEGRATIONS = { # # Integrations providing core functionality: "application_credentials", - "backup", "frontend", "hardware", "logger", @@ -149,6 +148,10 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = { # These integrations are set up if using the Supervisor "hassio", } +DEFAULT_INTEGRATIONS_NON_SUPERVISOR = { + # These integrations are set up if not using the Supervisor + "backup", +} CRITICAL_INTEGRATIONS = { # Recovery mode is activated if these integrations fail to set up "frontend", @@ -538,6 +541,8 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: # Add domains depending on if the Supervisor is used or not if "SUPERVISOR" in os.environ: domains.update(DEFAULT_INTEGRATIONS_SUPERVISOR) + else: + domains.update(DEFAULT_INTEGRATIONS_NON_SUPERVISOR) return domains diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8f19436fb1d..8ce8bee7793 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -14,27 +14,23 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" + if is_hassio(hass): + LOGGER.error( + "The backup integration is not supported on this installation method, " + "please remove it from your configuration" + ) + return False + backup_manager = BackupManager(hass) hass.data[DOMAIN] = backup_manager - with_hassio = is_hassio(hass) - - async_register_websocket_handlers(hass, with_hassio) - - if with_hassio: - if DOMAIN in config: - LOGGER.error( - "The backup integration is not supported on this installation method, " - "please remove it from your configuration" - ) - return True - async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" await backup_manager.generate_backup() hass.services.async_register(DOMAIN, "create", async_handle_create_service) + async_register_websocket_handlers(hass) async_register_http_views(hass) return True diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index c1eed4294c2..c203019cca9 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,18 +6,13 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .manager import BackupManager @callback -def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None: +def async_register_websocket_handlers(hass: HomeAssistant) -> None: """Register websocket commands.""" - if with_hassio: - websocket_api.async_register_command(hass, handle_backup_end) - websocket_api.async_register_command(hass, handle_backup_start) - return - websocket_api.async_register_command(hass, handle_info) websocket_api.async_register_command(hass, handle_create) websocket_api.async_register_command(hass, handle_remove) @@ -74,47 +69,3 @@ async def handle_create( manager: BackupManager = hass.data[DOMAIN] backup = await manager.generate_backup() connection.send_result(msg["id"], backup) - - -@websocket_api.ws_require_user(only_supervisor=True) -@websocket_api.websocket_command({vol.Required("type"): "backup/start"}) -@websocket_api.async_response -async def handle_backup_start( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Backup start notification.""" - manager: BackupManager = hass.data[DOMAIN] - manager.backing_up = True - LOGGER.debug("Backup start notification") - - try: - await manager.pre_backup_actions() - except Exception as err: # pylint: disable=broad-except - connection.send_error(msg["id"], "pre_backup_actions_failed", str(err)) - return - - connection.send_result(msg["id"]) - - -@websocket_api.ws_require_user(only_supervisor=True) -@websocket_api.websocket_command({vol.Required("type"): "backup/end"}) -@websocket_api.async_response -async def handle_backup_end( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Backup end notification.""" - manager: BackupManager = hass.data[DOMAIN] - manager.backing_up = False - LOGGER.debug("Backup end notification") - - try: - await manager.post_backup_actions() - except Exception as err: # pylint: disable=broad-except - connection.send_error(msg["id"], "post_backup_actions_failed", str(err)) - return - - connection.send_result(msg["id"]) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index f2b4df1d0cc..af61faf921e 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime as dt +import logging from typing import Any, Literal, cast import voluptuous as vol @@ -45,6 +46,8 @@ from .statistics import ( ) from .util import PERIOD_SCHEMA, get_instance, resolve_period +_LOGGER: logging.Logger = logging.getLogger(__package__) + UNIT_SCHEMA = vol.Schema( { vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), @@ -70,6 +73,8 @@ UNIT_SCHEMA = vol.Schema( def async_setup(hass: HomeAssistant) -> None: """Set up the recorder websocket API.""" websocket_api.async_register_command(hass, ws_adjust_sum_statistics) + websocket_api.async_register_command(hass, ws_backup_end) + websocket_api.async_register_command(hass, ws_backup_start) websocket_api.async_register_command(hass, ws_change_statistics_unit) websocket_api.async_register_command(hass, ws_clear_statistics) websocket_api.async_register_command(hass, ws_get_statistic_during_period) @@ -512,3 +517,38 @@ def ws_info( "thread_running": is_running, } connection.send_result(msg["id"], recorder_info) + + +@websocket_api.ws_require_user(only_supervisor=True) +@websocket_api.websocket_command({vol.Required("type"): "backup/start"}) +@websocket_api.async_response +async def ws_backup_start( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Backup start notification.""" + + _LOGGER.info("Backup start notification, locking database for writes") + instance = get_instance(hass) + try: + await instance.lock_database() + except TimeoutError as err: + connection.send_error(msg["id"], "timeout_error", str(err)) + return + connection.send_result(msg["id"]) + + +@websocket_api.ws_require_user(only_supervisor=True) +@websocket_api.websocket_command({vol.Required("type"): "backup/end"}) +@websocket_api.async_response +async def ws_backup_end( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Backup end notification.""" + + instance = get_instance(hass) + _LOGGER.info("Backup end notification, releasing write lock") + if not instance.unlock_database(): + connection.send_error( + msg["id"], "database_unlock_failed", "Failed to unlock database." + ) + connection.send_result(msg["id"]) diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr deleted file mode 100644 index a1d83f5cd75..00000000000 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ /dev/null @@ -1,223 +0,0 @@ -# serializer version: 1 -# name: test_backup_end[with_hassio-hass_access_token] - dict({ - 'error': dict({ - 'code': 'only_supervisor', - 'message': 'Only allowed as Supervisor', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_end[with_hassio-hass_supervisor_access_token] - dict({ - 'id': 1, - 'result': None, - 'success': True, - 'type': 'result', - }) -# --- -# name: test_backup_end[without_hassio-hass_access_token] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_end[without_hassio-hass_supervisor_access_token] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_end_excepion[exception0] - dict({ - 'error': dict({ - 'code': 'post_backup_actions_failed', - 'message': '', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_end_excepion[exception1] - dict({ - 'error': dict({ - 'code': 'post_backup_actions_failed', - 'message': 'Boom', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_end_excepion[exception2] - dict({ - 'error': dict({ - 'code': 'post_backup_actions_failed', - 'message': 'Boom', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_start[with_hassio-hass_access_token] - dict({ - 'error': dict({ - 'code': 'only_supervisor', - 'message': 'Only allowed as Supervisor', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_start[with_hassio-hass_supervisor_access_token] - dict({ - 'id': 1, - 'result': None, - 'success': True, - 'type': 'result', - }) -# --- -# name: test_backup_start[without_hassio-hass_access_token] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_start[without_hassio-hass_supervisor_access_token] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_start_excepion[exception0] - dict({ - 'error': dict({ - 'code': 'pre_backup_actions_failed', - 'message': '', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_start_excepion[exception1] - dict({ - 'error': dict({ - 'code': 'pre_backup_actions_failed', - 'message': 'Boom', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_backup_start_excepion[exception2] - dict({ - 'error': dict({ - 'code': 'pre_backup_actions_failed', - 'message': 'Boom', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_generate[with_hassio] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_generate[without_hassio] - dict({ - 'id': 1, - 'result': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_info[with_hassio] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_info[without_hassio] - dict({ - 'id': 1, - 'result': dict({ - 'backing_up': False, - 'backups': list([ - dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', - }), - ]), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_remove[with_hassio] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_remove[without_hassio] - dict({ - 'id': 1, - 'result': None, - 'success': True, - 'type': 'result', - }) -# --- diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 1e164abb1bb..86055889da5 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -14,11 +14,7 @@ async def test_setup_with_hassio( caplog: pytest.LogCaptureFixture, ) -> None: """Test the setup of the integration with hassio enabled.""" - assert await setup_backup_integration( - hass=hass, - with_hassio=True, - configuration={DOMAIN: {}}, - ) + assert not await setup_backup_integration(hass=hass, with_hassio=True) assert ( "The backup integration is not supported on this installation method, please" " remove it from your configuration" diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index e79b958be20..5a50f1afa8a 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,43 +2,20 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .common import TEST_BACKUP, setup_backup_integration from tests.typing import WebSocketGenerator -@pytest.fixture -def sync_access_token_proxy( - access_token_fixture_name: str, - request: pytest.FixtureRequest, -) -> str: - """Non-async proxy for the *_access_token fixture. - - Workaround for https://github.com/pytest-dev/pytest-asyncio/issues/112 - """ - return request.getfixturevalue(access_token_fixture_name) - - -@pytest.mark.parametrize( - "with_hassio", - ( - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ), -) async def test_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test getting backup info.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration(hass) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -47,25 +24,21 @@ async def test_info( "homeassistant.components.backup.websocket.BackupManager.get_backups", return_value={TEST_BACKUP.slug: TEST_BACKUP}, ): - await client.send_json_auto_id({"type": "backup/info"}) - assert snapshot == await client.receive_json() + await client.send_json({"id": 1, "type": "backup/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"backing_up": False, "backups": [TEST_BACKUP.as_dict()]} -@pytest.mark.parametrize( - "with_hassio", - ( - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ), -) async def test_remove( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - with_hassio: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removing a backup file.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration(hass) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -73,25 +46,19 @@ async def test_remove( with patch( "homeassistant.components.backup.websocket.BackupManager.remove_backup", ): - await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) - assert snapshot == await client.receive_json() + await client.send_json({"id": 1, "type": "backup/remove", "slug": "abc123"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] -@pytest.mark.parametrize( - "with_hassio", - ( - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ), -) async def test_generate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: - """Test generating a backup.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + """Test removing a backup file.""" + await setup_backup_integration(hass) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -100,130 +67,9 @@ async def test_generate( "homeassistant.components.backup.websocket.BackupManager.generate_backup", return_value=TEST_BACKUP, ): - await client.send_json_auto_id({"type": "backup/generate"}) - assert snapshot == await client.receive_json() + await client.send_json({"id": 1, "type": "backup/generate"}) + msg = await client.receive_json() - -@pytest.mark.parametrize( - "access_token_fixture_name", - ["hass_access_token", "hass_supervisor_access_token"], -) -@pytest.mark.parametrize( - ("with_hassio"), - ( - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ), -) -async def test_backup_end( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - request: pytest.FixtureRequest, - sync_access_token_proxy: str, - *, - access_token_fixture_name: str, - with_hassio: bool, -) -> None: - """Test handling of post backup actions from a WS command.""" - await setup_backup_integration(hass, with_hassio=with_hassio) - - client = await hass_ws_client(hass, sync_access_token_proxy) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.backup.websocket.BackupManager.post_backup_actions", - ): - await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() - - -@pytest.mark.parametrize( - "access_token_fixture_name", - ["hass_access_token", "hass_supervisor_access_token"], -) -@pytest.mark.parametrize( - ("with_hassio"), - ( - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ), -) -async def test_backup_start( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - sync_access_token_proxy: str, - *, - access_token_fixture_name: str, - with_hassio: bool, -) -> None: - """Test handling of pre backup actions from a WS command.""" - await setup_backup_integration(hass, with_hassio=with_hassio) - - client = await hass_ws_client(hass, sync_access_token_proxy) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.backup.websocket.BackupManager.pre_backup_actions", - ): - await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() - - -@pytest.mark.parametrize( - "exception", - ( - TimeoutError(), - HomeAssistantError("Boom"), - Exception("Boom"), - ), -) -async def test_backup_end_excepion( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - hass_supervisor_access_token: str, - exception: Exception, -) -> None: - """Test exception handling while running post backup actions from a WS command.""" - await setup_backup_integration(hass, with_hassio=True) - - client = await hass_ws_client(hass, hass_supervisor_access_token) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.backup.websocket.BackupManager.post_backup_actions", - side_effect=exception, - ): - await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() - - -@pytest.mark.parametrize( - "exception", - ( - TimeoutError(), - HomeAssistantError("Boom"), - Exception("Boom"), - ), -) -async def test_backup_start_excepion( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - hass_supervisor_access_token: str, - exception: Exception, -) -> None: - """Test exception handling while running pre backup actions from a WS command.""" - await setup_backup_integration(hass, with_hassio=True) - - client = await hass_ws_client(hass, hass_supervisor_access_token) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.backup.websocket.BackupManager.pre_backup_actions", - side_effect=exception, - ): - await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == TEST_BACKUP.as_dict() diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index d35be0abc9b..fff8daa14f4 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2227,6 +2227,77 @@ async def test_backup_start_no_recorder( assert response["error"]["code"] == "unknown_command" +async def test_backup_start_timeout( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_supervisor_access_token: str, + recorder_db_url: str, +) -> None: + """Test getting backup start when recorder is not present.""" + if recorder_db_url.startswith(("mysql://", "postgresql://")): + # This test is specific for SQLite: Locking is not implemented for other engines + return + + client = await hass_ws_client(hass, hass_supervisor_access_token) + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + with patch.object(recorder.core, "DB_LOCK_TIMEOUT", 0): + try: + await client.send_json_auto_id({"type": "backup/start"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "timeout_error" + finally: + await client.send_json_auto_id({"type": "backup/end"}) + + +async def test_backup_end( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_supervisor_access_token: str, +) -> None: + """Test backup start.""" + client = await hass_ws_client(hass, hass_supervisor_access_token) + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + await client.send_json_auto_id({"type": "backup/start"}) + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id({"type": "backup/end"}) + response = await client.receive_json() + assert response["success"] + + +async def test_backup_end_without_start( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_supervisor_access_token: str, + recorder_db_url: str, +) -> None: + """Test backup start.""" + if recorder_db_url.startswith(("mysql://", "postgresql://")): + # This test is specific for SQLite: Locking is not implemented for other engines + return + + client = await hass_ws_client(hass, hass_supervisor_access_token) + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + await client.send_json_auto_id({"type": "backup/end"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "database_unlock_failed" + + @pytest.mark.parametrize( ("units", "attributes", "unit", "unit_class"), [ diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index fa58075e095..a899b3b3d6c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -95,6 +95,15 @@ async def test_load_hassio(hass: HomeAssistant) -> None: assert "hassio" in bootstrap._get_domains(hass, {}) +async def test_load_backup(hass: HomeAssistant) -> None: + """Test that we load the backup integration when not using Supervisor.""" + with patch.dict(os.environ, {}, clear=True): + assert "backup" in bootstrap._get_domains(hass, {}) + + with patch.dict(os.environ, {"SUPERVISOR": "1"}): + assert "backup" not in bootstrap._get_domains(hass, {}) + + @pytest.mark.parametrize("load_registries", [False]) async def test_empty_setup(hass: HomeAssistant) -> None: """Test an empty set up loads the core."""