diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7cec74c9dce..09c07d1b364 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -115,6 +115,7 @@ DEFAULT_INTEGRATIONS = { # # Integrations providing core functionality: "application_credentials", + "backup", "frontend", "hardware", "logger", @@ -148,10 +149,6 @@ 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", @@ -541,8 +538,6 @@ 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 8ce8bee7793..8f19436fb1d 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -14,23 +14,27 @@ 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 c203019cca9..c1eed4294c2 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,13 +6,18 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .manager import BackupManager @callback -def async_register_websocket_handlers(hass: HomeAssistant) -> None: +def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> 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) @@ -69,3 +74,47 @@ 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 af61faf921e..f2b4df1d0cc 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime as dt -import logging from typing import Any, Literal, cast import voluptuous as vol @@ -46,8 +45,6 @@ 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), @@ -73,8 +70,6 @@ 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) @@ -517,38 +512,3 @@ 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 new file mode 100644 index 00000000000..a1d83f5cd75 --- /dev/null +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -0,0 +1,223 @@ +# 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 86055889da5..1e164abb1bb 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -14,7 +14,11 @@ async def test_setup_with_hassio( caplog: pytest.LogCaptureFixture, ) -> None: """Test the setup of the integration with hassio enabled.""" - assert not await setup_backup_integration(hass=hass, with_hassio=True) + assert await setup_backup_integration( + hass=hass, + with_hassio=True, + configuration={DOMAIN: {}}, + ) 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 5a50f1afa8a..e79b958be20 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,20 +2,43 @@ 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) + await setup_backup_integration(hass, with_hassio=with_hassio) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -24,21 +47,25 @@ async def test_info( "homeassistant.components.backup.websocket.BackupManager.get_backups", return_value={TEST_BACKUP.slug: TEST_BACKUP}, ): - 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()]} + await client.send_json_auto_id({"type": "backup/info"}) + assert snapshot == await client.receive_json() +@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, - caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, + with_hassio: bool, ) -> None: """Test removing a backup file.""" - await setup_backup_integration(hass) + await setup_backup_integration(hass, with_hassio=with_hassio) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -46,19 +73,25 @@ async def test_remove( with patch( "homeassistant.components.backup.websocket.BackupManager.remove_backup", ): - await client.send_json({"id": 1, "type": "backup/remove", "slug": "abc123"}) - msg = await client.receive_json() - - assert msg["id"] == 1 - assert msg["success"] + await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) + assert snapshot == await client.receive_json() +@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 removing a backup file.""" - await setup_backup_integration(hass) + """Test generating a backup.""" + await setup_backup_integration(hass, with_hassio=with_hassio) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -67,9 +100,130 @@ async def test_generate( "homeassistant.components.backup.websocket.BackupManager.generate_backup", return_value=TEST_BACKUP, ): - await client.send_json({"id": 1, "type": "backup/generate"}) - msg = await client.receive_json() + await client.send_json_auto_id({"type": "backup/generate"}) + assert snapshot == await client.receive_json() - assert msg["id"] == 1 - assert msg["success"] - assert msg["result"] == TEST_BACKUP.as_dict() + +@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() diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index fff8daa14f4..d35be0abc9b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2227,77 +2227,6 @@ 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 a899b3b3d6c..fa58075e095 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -95,15 +95,6 @@ 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."""