backups: Add support for creating fully uncompressed backups (#3378)

Hassio supervisor saves backups in tar files that contains compressed
tar archives, this is convenient when such backups are kept in the same
environment or need to be transferred remotely, but it's not convenient
when they will be processed using other backup tools such as borg or
restic that can handle compression, encryption and data deduplication
themselves.

In fact deduplication won't actually work at all with hassio compressed
backups as there's no way to find common streams for such tools (unless
we make them to export the archives during importing as borg's
import-tar can do), but this would lead to archives that are not easily
recoverable by the supervisor.

So, make possible to pass a "compressed" boolean parameter when creating
backups that will just archive all the data uncompressed.

It will be then up to other tools to manage the archives compression.
This commit is contained in:
Marco Trevisan 2022-02-03 10:24:44 +01:00 committed by GitHub
parent a52272a7fe
commit 80e67b3c57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 112 additions and 13 deletions

View File

@ -13,6 +13,7 @@ from ..backups.validate import ALL_FOLDERS
from ..const import ( from ..const import (
ATTR_ADDONS, ATTR_ADDONS,
ATTR_BACKUPS, ATTR_BACKUPS,
ATTR_COMPRESSED,
ATTR_CONTENT, ATTR_CONTENT,
ATTR_DATE, ATTR_DATE,
ATTR_FOLDERS, ATTR_FOLDERS,
@ -51,6 +52,7 @@ SCHEMA_BACKUP_FULL = vol.Schema(
{ {
vol.Optional(ATTR_NAME): str, vol.Optional(ATTR_NAME): str,
vol.Optional(ATTR_PASSWORD): vol.Maybe(str), vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
} }
) )
@ -86,6 +88,7 @@ class APIBackups(CoreSysAttributes):
ATTR_TYPE: backup.sys_type, ATTR_TYPE: backup.sys_type,
ATTR_SIZE: backup.size, ATTR_SIZE: backup.size,
ATTR_PROTECTED: backup.protected, ATTR_PROTECTED: backup.protected,
ATTR_COMPRESSED: backup.compressed,
ATTR_CONTENT: { ATTR_CONTENT: {
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None, ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
ATTR_ADDONS: backup.addon_list, ATTR_ADDONS: backup.addon_list,
@ -128,6 +131,7 @@ class APIBackups(CoreSysAttributes):
ATTR_NAME: backup.name, ATTR_NAME: backup.name,
ATTR_DATE: backup.date, ATTR_DATE: backup.date,
ATTR_SIZE: backup.size, ATTR_SIZE: backup.size,
ATTR_COMPRESSED: backup.compressed,
ATTR_PROTECTED: backup.protected, ATTR_PROTECTED: backup.protected,
ATTR_HOMEASSISTANT: backup.homeassistant_version, ATTR_HOMEASSISTANT: backup.homeassistant_version,
ATTR_ADDONS: data_addons, ATTR_ADDONS: data_addons,

View File

@ -19,6 +19,7 @@ from ..const import (
ATTR_AUDIO_INPUT, ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT, ATTR_AUDIO_OUTPUT,
ATTR_BOOT, ATTR_BOOT,
ATTR_COMPRESSED,
ATTR_CRYPTO, ATTR_CRYPTO,
ATTR_DATE, ATTR_DATE,
ATTR_DOCKER, ATTR_DOCKER,
@ -90,6 +91,11 @@ class Backup(CoreSysAttributes):
"""Return backup date.""" """Return backup date."""
return self._data.get(ATTR_PROTECTED) is not None return self._data.get(ATTR_PROTECTED) is not None
@property
def compressed(self):
"""Return whether backup is compressed."""
return self._data.get(ATTR_COMPRESSED)
@property @property
def addons(self): def addons(self):
"""Return backup date.""" """Return backup date."""
@ -152,7 +158,7 @@ class Backup(CoreSysAttributes):
"""Return path to backup tarfile.""" """Return path to backup tarfile."""
return self._tarfile return self._tarfile
def new(self, slug, name, date, sys_type, password=None): def new(self, slug, name, date, sys_type, password=None, compressed=True):
"""Initialize a new backup.""" """Initialize a new backup."""
# Init metadata # Init metadata
self._data[ATTR_SLUG] = slug self._data[ATTR_SLUG] = slug
@ -169,6 +175,9 @@ class Backup(CoreSysAttributes):
self._data[ATTR_PROTECTED] = password_for_validating(password) self._data[ATTR_PROTECTED] = password_for_validating(password)
self._data[ATTR_CRYPTO] = CRYPTO_AES128 self._data[ATTR_CRYPTO] = CRYPTO_AES128
if not compressed:
self._data[ATTR_COMPRESSED] = False
def set_password(self, password: str) -> bool: def set_password(self, password: str) -> bool:
"""Set the password for an existing backup.""" """Set the password for an existing backup."""
if not password: if not password:
@ -306,8 +315,9 @@ class Backup(CoreSysAttributes):
async def _addon_save(addon: Addon): async def _addon_save(addon: Addon):
"""Task to store an add-on into backup.""" """Task to store an add-on into backup."""
tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile( addon_file = SecureTarFile(
Path(self._tmp.name, f"{addon.slug}.tar.gz"), "w", key=self._key Path(self._tmp.name, tar_name), "w", key=self._key, gzip=self.compressed
) )
# Take backup # Take backup
@ -340,8 +350,9 @@ class Backup(CoreSysAttributes):
async def _addon_restore(addon_slug: str): async def _addon_restore(addon_slug: str):
"""Task to restore an add-on into backup.""" """Task to restore an add-on into backup."""
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile( addon_file = SecureTarFile(
Path(self._tmp.name, f"{addon_slug}.tar.gz"), "r", key=self._key Path(self._tmp.name, tar_name), "r", key=self._key, gzip=self.compressed
) )
# If exists inside backup # If exists inside backup
@ -369,7 +380,9 @@ class Backup(CoreSysAttributes):
def _folder_save(name: str): def _folder_save(name: str):
"""Take backup of a folder.""" """Take backup of a folder."""
slug_name = name.replace("/", "_") slug_name = name.replace("/", "_")
tar_name = Path(self._tmp.name, f"{slug_name}.tar.gz") tar_name = Path(
self._tmp.name, f"{slug_name}.tar{'.gz' if self.compressed else ''}"
)
origin_dir = Path(self.sys_config.path_supervisor, name) origin_dir = Path(self.sys_config.path_supervisor, name)
# Check if exists # Check if exists
@ -380,7 +393,9 @@ class Backup(CoreSysAttributes):
# Take backup # Take backup
try: try:
_LOGGER.info("Backing up folder %s", name) _LOGGER.info("Backing up folder %s", name)
with SecureTarFile(tar_name, "w", key=self._key) as tar_file: with SecureTarFile(
tar_name, "w", key=self._key, gzip=self.compressed
) as tar_file:
atomic_contents_add( atomic_contents_add(
tar_file, tar_file,
origin_dir, origin_dir,
@ -407,7 +422,9 @@ class Backup(CoreSysAttributes):
def _folder_restore(name: str): def _folder_restore(name: str):
"""Intenal function to restore a folder.""" """Intenal function to restore a folder."""
slug_name = name.replace("/", "_") slug_name = name.replace("/", "_")
tar_name = Path(self._tmp.name, f"{slug_name}.tar.gz") tar_name = Path(
self._tmp.name, f"{slug_name}.tar{'.gz' if self.compressed else ''}"
)
origin_dir = Path(self.sys_config.path_supervisor, name) origin_dir = Path(self.sys_config.path_supervisor, name)
# Check if exists inside backup # Check if exists inside backup
@ -422,7 +439,9 @@ class Backup(CoreSysAttributes):
# Perform a restore # Perform a restore
try: try:
_LOGGER.info("Restore folder %s", name) _LOGGER.info("Restore folder %s", name)
with SecureTarFile(tar_name, "r", key=self._key) as tar_file: with SecureTarFile(
tar_name, "r", key=self._key, gzip=self.compressed
) as tar_file:
tar_file.extractall(path=origin_dir, members=tar_file) tar_file.extractall(path=origin_dir, members=tar_file)
_LOGGER.info("Restore folder %s done", name) _LOGGER.info("Restore folder %s done", name)
except (tarfile.TarError, OSError) as err: except (tarfile.TarError, OSError) as err:

View File

@ -40,7 +40,9 @@ class BackupManager(CoreSysAttributes):
"""Return backup object.""" """Return backup object."""
return self._backups.get(slug) return self._backups.get(slug)
def _create_backup(self, name, sys_type, password, homeassistant=True): def _create_backup(
self, name, sys_type, password, compressed=True, homeassistant=True
):
"""Initialize a new backup object from name.""" """Initialize a new backup object from name."""
date_str = utcnow().isoformat() date_str = utcnow().isoformat()
slug = create_slug(name, date_str) slug = create_slug(name, date_str)
@ -48,7 +50,7 @@ class BackupManager(CoreSysAttributes):
# init object # init object
backup = Backup(self.coresys, tar_file) backup = Backup(self.coresys, tar_file)
backup.new(slug, name, date_str, sys_type, password) backup.new(slug, name, date_str, sys_type, password, compressed)
# set general data # set general data
if homeassistant: if homeassistant:
@ -165,13 +167,13 @@ class BackupManager(CoreSysAttributes):
self.sys_core.state = CoreState.RUNNING self.sys_core.state = CoreState.RUNNING
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING]) @Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
async def do_backup_full(self, name="", password=None): async def do_backup_full(self, name="", password=None, compressed=True):
"""Create a full backup.""" """Create a full backup."""
if self.lock.locked(): if self.lock.locked():
_LOGGER.error("A backup/restore process is already running") _LOGGER.error("A backup/restore process is already running")
return None return None
backup = self._create_backup(name, BackupType.FULL, password) backup = self._create_backup(name, BackupType.FULL, password, compressed)
_LOGGER.info("Creating new full backup with slug %s", backup.slug) _LOGGER.info("Creating new full backup with slug %s", backup.slug)
async with self.lock: async with self.lock:
@ -184,7 +186,13 @@ class BackupManager(CoreSysAttributes):
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING]) @Job(conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING])
async def do_backup_partial( async def do_backup_partial(
self, name="", addons=None, folders=None, password=None, homeassistant=True self,
name="",
addons=None,
folders=None,
password=None,
homeassistant=True,
compressed=True,
): ):
"""Create a partial backup.""" """Create a partial backup."""
if self.lock.locked(): if self.lock.locked():
@ -197,7 +205,9 @@ class BackupManager(CoreSysAttributes):
if len(addons) == 0 and len(folders) == 0 and not homeassistant: if len(addons) == 0 and len(folders) == 0 and not homeassistant:
_LOGGER.error("Nothing to create backup for") _LOGGER.error("Nothing to create backup for")
backup = self._create_backup(name, BackupType.PARTIAL, password, homeassistant) backup = self._create_backup(
name, BackupType.PARTIAL, password, compressed, homeassistant
)
_LOGGER.info("Creating new partial backup with slug %s", backup.slug) _LOGGER.info("Creating new partial backup with slug %s", backup.slug)
async with self.lock: async with self.lock:

View File

@ -7,6 +7,7 @@ from ..const import (
ATTR_AUDIO_INPUT, ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT, ATTR_AUDIO_OUTPUT,
ATTR_BOOT, ATTR_BOOT,
ATTR_COMPRESSED,
ATTR_CRYPTO, ATTR_CRYPTO,
ATTR_DATE, ATTR_DATE,
ATTR_DOCKER, ATTR_DOCKER,
@ -65,6 +66,7 @@ SCHEMA_BACKUP = vol.Schema(
vol.Required(ATTR_TYPE): vol.Coerce(BackupType), vol.Required(ATTR_TYPE): vol.Coerce(BackupType),
vol.Required(ATTR_NAME): str, vol.Required(ATTR_NAME): str,
vol.Required(ATTR_DATE): str, vol.Required(ATTR_DATE): str,
vol.Optional(ATTR_COMPRESSED, default=True): vol.Boolean(),
vol.Inclusive(ATTR_PROTECTED, "encrypted"): vol.All( vol.Inclusive(ATTR_PROTECTED, "encrypted"): vol.All(
str, vol.Length(min=1, max=1) str, vol.Length(min=1, max=1)
), ),

View File

@ -132,6 +132,7 @@ ATTR_CHANNEL = "channel"
ATTR_CHASSIS = "chassis" ATTR_CHASSIS = "chassis"
ATTR_CHECKS = "checks" ATTR_CHECKS = "checks"
ATTR_CLI = "cli" ATTR_CLI = "cli"
ATTR_COMPRESSED = "compressed"
ATTR_CONFIG = "config" ATTR_CONFIG = "config"
ATTR_CONFIGURATION = "configuration" ATTR_CONFIGURATION = "configuration"
ATTR_CONNECTED = "connected" ATTR_CONNECTED = "connected"

View File

@ -23,6 +23,37 @@ async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
# Check Backup has been created without password # Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.FULL assert backup_instance.new.call_args[0][3] == BackupType.FULL
assert backup_instance.new.call_args[0][4] is None assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is True
backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once()
backup_instance.store_dockerconfig.assert_called_once()
backup_instance.store_addons.assert_called_once()
assert install_addon_ssh in backup_instance.store_addons.call_args[0][0]
backup_instance.store_folders.assert_called_once()
assert len(backup_instance.store_folders.call_args[0][0]) == 4
assert coresys.core.state == CoreState.RUNNING
async def test_do_backup_full_uncompressed(
coresys: CoreSys, backup_mock, install_addon_ssh
):
"""Test creating Backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
manager = BackupManager(coresys)
# backup_mock fixture causes Backup() to be a MagicMock
backup_instance: MagicMock = await manager.do_backup_full(compressed=False)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.FULL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is False
backup_instance.store_homeassistant.assert_called_once() backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once() backup_instance.store_repositories.assert_called_once()
@ -53,6 +84,37 @@ async def test_do_backup_partial_minimal(
# Check Backup has been created without password # Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is True
backup_instance.store_homeassistant.assert_not_called()
backup_instance.store_repositories.assert_called_once()
backup_instance.store_dockerconfig.assert_called_once()
backup_instance.store_addons.assert_not_called()
backup_instance.store_folders.assert_not_called()
assert coresys.core.state == CoreState.RUNNING
async def test_do_backup_partial_minimal_uncompressed(
coresys: CoreSys, backup_mock, install_addon_ssh
):
"""Test creating minimal partial Backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
manager = BackupManager(coresys)
# backup_mock fixture causes Backup() to be a MagicMock
backup_instance: MagicMock = await manager.do_backup_partial(
homeassistant=False, compressed=False
)
# Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is False
backup_instance.store_homeassistant.assert_not_called() backup_instance.store_homeassistant.assert_not_called()
backup_instance.store_repositories.assert_called_once() backup_instance.store_repositories.assert_called_once()
@ -84,6 +146,7 @@ async def test_do_backup_partial_maximal(
# Check Backup has been created without password # Check Backup has been created without password
assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL assert backup_instance.new.call_args[0][3] == BackupType.PARTIAL
assert backup_instance.new.call_args[0][4] is None assert backup_instance.new.call_args[0][4] is None
assert backup_instance.new.call_args[0][5] is True
backup_instance.store_homeassistant.assert_called_once() backup_instance.store_homeassistant.assert_called_once()
backup_instance.store_repositories.assert_called_once() backup_instance.store_repositories.assert_called_once()