Move backup/* WS commands to the backup integration (#110651)

* Move backup/* WS commands to the backup integration

* Call correct command

* Use debug for logging

* Remove assertion of hass.data for setup test

* parametrize token fixture
pull/106311/head^2
Joakim Sørensen 2024-02-22 10:25:38 +01:00 committed by GitHub
parent 52621f9609
commit ec4e6c3a74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 467 additions and 158 deletions

View File

@ -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

View File

@ -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

View File

@ -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"])

View File

@ -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"])

View File

@ -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',
})
# ---

View File

@ -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"

View File

@ -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()

View File

@ -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"),
[

View File

@ -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."""