Notify HA Core when backup is being executed (#3305)

* Notify HA Core when backup is being executed

Notify Home Assistant Core using the Websocket API when the Home Assistant
config directory is about to get backed up. This makes sure the Core can
prepare the SQLite database (if active) so it safe to make a backup.

* Only close WebSocket connection on connection errors

Let regular WebSocket errors bubble to the caller so they can be handled
explicitly.

* Add version restriction to backup/start/end WS commands

* Restore Home Assistant metadata when Home Assistant is selected
This commit is contained in:
Stefan Agner 2022-01-05 11:57:41 +01:00 committed by GitHub
parent 809ac1ffca
commit 1799c765b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 126 additions and 25 deletions

View File

@ -52,17 +52,6 @@ from .validate import SCHEMA_BACKUP
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
MAP_FOLDER_EXCLUDE = {
FOLDER_HOMEASSISTANT: [
"*.db-wal",
"*.db-shm",
"__pycache__/*",
"*.log",
"*.log.*",
"OZW_Log.txt",
]
}
class Backup(CoreSysAttributes): class Backup(CoreSysAttributes):
"""A single Supervisor backup.""" """A single Supervisor backup."""
@ -395,7 +384,7 @@ class Backup(CoreSysAttributes):
atomic_contents_add( atomic_contents_add(
tar_file, tar_file,
origin_dir, origin_dir,
excludes=MAP_FOLDER_EXCLUDE.get(name, []), excludes=[],
arcname=".", arcname=".",
) )
@ -486,6 +475,27 @@ class Backup(CoreSysAttributes):
# save # save
self.sys_homeassistant.save_data() self.sys_homeassistant.save_data()
async def store_homeassistant_config_dir(self):
"""Backup Home Assitant Core configuration folder."""
# Backup Home Assistant Core config directory
homeassistant_file = SecureTarFile(
Path(self._tmp.name, "homeassistant.tar.gz"), "w", key=self._key
)
await self.sys_homeassistant.backup(homeassistant_file)
self._data[ATTR_FOLDERS].append(FOLDER_HOMEASSISTANT)
async def restore_homeassistant_config_dir(self):
"""Restore Home Assitant Core configuration folder."""
# Restore Home Assistant Core config directory
homeassistant_file = SecureTarFile(
Path(self._tmp.name, "homeassistant.tar.gz"), "r", key=self._key
)
await self.sys_homeassistant.restore(homeassistant_file)
def store_repositories(self): def store_repositories(self):
"""Store repository list into backup.""" """Store repository list into backup."""
self.repositories = self.sys_config.addons_repositories self.repositories = self.sys_config.addons_repositories

View File

@ -145,6 +145,11 @@ class BackupManager(CoreSysAttributes):
await backup.store_addons(addon_list) await backup.store_addons(addon_list)
# Backup folders # Backup folders
if FOLDER_HOMEASSISTANT in folder_list:
await backup.store_homeassistant_config_dir()
folder_list = list(folder_list)
folder_list.remove(FOLDER_HOMEASSISTANT)
if folder_list: if folder_list:
_LOGGER.info("Backing up %s store folders", backup.slug) _LOGGER.info("Backing up %s store folders", backup.slug)
await backup.store_folders(folder_list) await backup.store_folders(folder_list)
@ -153,11 +158,9 @@ class BackupManager(CoreSysAttributes):
_LOGGER.exception("Backup %s error", backup.slug) _LOGGER.exception("Backup %s error", backup.slug)
self.sys_capture_exception(err) self.sys_capture_exception(err)
return None return None
else: else:
self._backups[backup.slug] = backup self._backups[backup.slug] = backup
return backup return backup
finally: finally:
self.sys_core.state = CoreState.RUNNING self.sys_core.state = CoreState.RUNNING
@ -232,7 +235,9 @@ class BackupManager(CoreSysAttributes):
backup.restore_dockerconfig() backup.restore_dockerconfig()
if FOLDER_HOMEASSISTANT in folder_list: if FOLDER_HOMEASSISTANT in folder_list:
backup.restore_homeassistant() await backup.restore_homeassistant_config_dir()
folder_list = list(folder_list)
folder_list.remove(FOLDER_HOMEASSISTANT)
# Process folders # Process folders
if folder_list: if folder_list:
@ -243,6 +248,7 @@ class BackupManager(CoreSysAttributes):
task_hass = None task_hass = None
if homeassistant: if homeassistant:
_LOGGER.info("Restoring %s Home Assistant Core", backup.slug) _LOGGER.info("Restoring %s Home Assistant Core", backup.slug)
backup.restore_homeassistant()
task_hass = self._update_core_task(backup.homeassistant_version) task_hass = self._update_core_task(backup.homeassistant_version)
if addon_list: if addon_list:

View File

@ -7,8 +7,6 @@ from ..const import CoreState
LANDINGPAGE: AwesomeVersion = AwesomeVersion("landingpage") LANDINGPAGE: AwesomeVersion = AwesomeVersion("landingpage")
MIN_VERSION = {"supervisor/event": "2021.2.4"}
CLOSING_STATES = [ CLOSING_STATES = [
CoreState.SHUTDOWN, CoreState.SHUTDOWN,
CoreState.STOPPING, CoreState.STOPPING,
@ -21,6 +19,8 @@ class WSType(str, Enum):
AUTH = "auth" AUTH = "auth"
SUPERVISOR_EVENT = "supervisor/event" SUPERVISOR_EVENT = "supervisor/event"
BACKUP_START = "backup/start"
BACKUP_END = "backup/end"
class WSEvent(str, Enum): class WSEvent(str, Enum):

View File

@ -4,11 +4,16 @@ from ipaddress import IPv4Address
import logging import logging
from pathlib import Path from pathlib import Path
import shutil import shutil
import tarfile
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from awesomeversion import AwesomeVersion, AwesomeVersionException from awesomeversion import AwesomeVersion, AwesomeVersionException
from supervisor.exceptions import HomeAssistantWSError
from supervisor.homeassistant.const import WSType
from supervisor.utils.tar import SecureTarFile, atomic_contents_add
from ..const import ( from ..const import (
ATTR_ACCESS_TOKEN, ATTR_ACCESS_TOKEN,
ATTR_AUDIO_INPUT, ATTR_AUDIO_INPUT,
@ -24,6 +29,7 @@ from ..const import (
ATTR_WAIT_BOOT, ATTR_WAIT_BOOT,
ATTR_WATCHDOG, ATTR_WATCHDOG,
FILE_HASSIO_HOMEASSISTANT, FILE_HASSIO_HOMEASSISTANT,
FOLDER_HOMEASSISTANT,
BusEvent, BusEvent,
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
@ -39,6 +45,15 @@ from .websocket import HomeAssistantWebSocket
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
HOMEASSISTANT_BACKUP_EXCLUDE = [
"*.db-shm",
"__pycache__/*",
"*.log",
"*.log.*",
"OZW_Log.txt",
]
class HomeAssistant(FileConfiguration, CoreSysAttributes): class HomeAssistant(FileConfiguration, CoreSysAttributes):
"""Home Assistant core object for handle it.""" """Home Assistant core object for handle it."""
@ -284,3 +299,59 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
return return
self.sys_homeassistant.websocket.send_message({ATTR_TYPE: "usb/scan"}) self.sys_homeassistant.websocket.send_message({ATTR_TYPE: "usb/scan"})
async def backup(self, secure_tar_file: SecureTarFile) -> None:
"""Backup Home Assistant Core config/ directory."""
# Let Home Assistant Core know we are about to backup
try:
await self.websocket.async_send_command({ATTR_TYPE: WSType.BACKUP_START})
except HomeAssistantWSError:
_LOGGER.warning(
"Preparing backup of Home Assistant Core failed. Check HA Core logs."
)
def _write_tarfile():
with secure_tar_file as tar_file:
# Backup system
origin_dir = Path(self.sys_config.path_supervisor, FOLDER_HOMEASSISTANT)
# Backup data
atomic_contents_add(
tar_file,
origin_dir,
excludes=HOMEASSISTANT_BACKUP_EXCLUDE,
arcname=".",
)
try:
_LOGGER.info("Backing up Home Assistant Core config folder")
await self.sys_run_in_executor(_write_tarfile)
_LOGGER.info("Backup Home Assistant Core config folder done")
finally:
try:
await self.sys_homeassistant.websocket.async_send_command(
{ATTR_TYPE: WSType.BACKUP_END}
)
except HomeAssistantWSError:
_LOGGER.warning(
"Error during Home Assistant Core backup. Check HA Core logs."
)
async def restore(self, secure_tar_file: SecureTarFile) -> None:
"""Restore Home Assistant Core config/ directory."""
# Perform a restore
def _restore_tarfile():
origin_dir = Path(self.sys_config.path_supervisor, FOLDER_HOMEASSISTANT)
with secure_tar_file as tar_file:
tar_file.extractall(path=origin_dir, members=tar_file)
try:
_LOGGER.info("Restore Home Assistant Core config folder")
await self.sys_run_in_executor(_restore_tarfile)
_LOGGER.info("Restore Home Assistant Core config folder done")
except (tarfile.TarError, OSError) as err:
_LOGGER.warning("Can't restore Home Assistant Core config folder: %s", err)

View File

@ -17,7 +17,13 @@ from ..exceptions import (
HomeAssistantWSError, HomeAssistantWSError,
HomeAssistantWSNotSupported, HomeAssistantWSNotSupported,
) )
from .const import CLOSING_STATES, MIN_VERSION, WSEvent, WSType from .const import CLOSING_STATES, WSEvent, WSType
MIN_VERSION = {
WSType.SUPERVISOR_EVENT: "2021.2.4",
WSType.BACKUP_START: "2021.12.0",
WSType.BACKUP_END: "2021.12.0",
}
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -214,7 +220,7 @@ class HomeAssistantWebSocket(CoreSysAttributes):
try: try:
await self._client.async_send_command(message) await self._client.async_send_command(message)
except HomeAssistantWSError: except HomeAssistantWSConnectionError:
await self._client.close() await self._client.close()
self._client = None self._client = None
@ -225,7 +231,7 @@ class HomeAssistantWebSocket(CoreSysAttributes):
try: try:
return await self._client.async_send_command(message) return await self._client.async_send_command(message)
except HomeAssistantWSError: except HomeAssistantWSConnectionError:
await self._client.close() await self._client.close()
self._client = None self._client = None

View File

@ -19,8 +19,11 @@ def fixture_backup_mock():
backup_instance.store_addons = AsyncMock(return_value=None) backup_instance.store_addons = AsyncMock(return_value=None)
backup_instance.store_folders = AsyncMock(return_value=None) backup_instance.store_folders = AsyncMock(return_value=None)
backup_instance.restore_addons = AsyncMock(return_value=None) backup_instance.store_homeassistant_config_dir = AsyncMock(return_value=None)
backup_instance.store_addons = AsyncMock(return_value=None)
backup_instance.restore_folders = AsyncMock(return_value=None) backup_instance.restore_folders = AsyncMock(return_value=None)
backup_instance.restore_homeassistant_config_dir = AsyncMock(return_value=None)
backup_instance.restore_addons = AsyncMock(return_value=None)
backup_instance.restore_repositories = AsyncMock(return_value=None) backup_instance.restore_repositories = AsyncMock(return_value=None)
yield backup_mock yield backup_mock

View File

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
from supervisor.backups.const import BackupType from supervisor.backups.const import BackupType
from supervisor.backups.manager import BackupManager from supervisor.backups.manager import BackupManager
from supervisor.const import FOLDER_HOMEASSISTANT, CoreState from supervisor.const import FOLDER_HOMEASSISTANT, FOLDER_SHARE, CoreState
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from tests.const import TEST_ADDON_SLUG from tests.const import TEST_ADDON_SLUG
@ -32,7 +32,8 @@ async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
assert install_addon_ssh in backup_instance.store_addons.call_args[0][0] assert install_addon_ssh in backup_instance.store_addons.call_args[0][0]
backup_instance.store_folders.assert_called_once() backup_instance.store_folders.assert_called_once()
assert len(backup_instance.store_folders.call_args[0][0]) == 5 assert len(backup_instance.store_folders.call_args[0][0]) == 4
backup_instance.store_homeassistant_config_dir.assert_called_once()
assert coresys.core.state == CoreState.RUNNING assert coresys.core.state == CoreState.RUNNING
@ -75,7 +76,9 @@ async def test_do_backup_partial_maximal(
# backup_mock fixture causes Backup() to be a MagicMock # backup_mock fixture causes Backup() to be a MagicMock
backup_instance: MagicMock = await manager.do_backup_partial( backup_instance: MagicMock = await manager.do_backup_partial(
addons=[TEST_ADDON_SLUG], folders=["/test"], homeassistant=True addons=[TEST_ADDON_SLUG],
folders=[FOLDER_SHARE, FOLDER_HOMEASSISTANT],
homeassistant=True,
) )
# Check Backup has been created without password # Check Backup has been created without password
@ -91,6 +94,7 @@ async def test_do_backup_partial_maximal(
backup_instance.store_folders.assert_called_once() backup_instance.store_folders.assert_called_once()
assert len(backup_instance.store_folders.call_args[0][0]) == 1 assert len(backup_instance.store_folders.call_args[0][0]) == 1
backup_instance.store_homeassistant_config_dir.assert_called_once()
assert coresys.core.state == CoreState.RUNNING assert coresys.core.state == CoreState.RUNNING
@ -190,7 +194,7 @@ async def test_do_restore_partial_maximal(coresys: CoreSys, partial_backup_mock)
await manager.do_restore_partial( await manager.do_restore_partial(
backup_instance, backup_instance,
addons=[TEST_ADDON_SLUG], addons=[TEST_ADDON_SLUG],
folders=[FOLDER_HOMEASSISTANT], folders=[FOLDER_SHARE, FOLDER_HOMEASSISTANT],
homeassistant=True, homeassistant=True,
) )
@ -201,5 +205,6 @@ async def test_do_restore_partial_maximal(coresys: CoreSys, partial_backup_mock)
backup_instance.restore_addons.assert_called_once() backup_instance.restore_addons.assert_called_once()
backup_instance.restore_folders.assert_called_once() backup_instance.restore_folders.assert_called_once()
backup_instance.restore_homeassistant_config_dir.assert_called_once()
assert coresys.core.state == CoreState.RUNNING assert coresys.core.state == CoreState.RUNNING