Extend backup API with file name field (#5567)

* Extend backup API with file name field

Allow to specify a backup file name when creating a backup. This allows
for user friendly backup file names. If none is specified, the current
behavior remains (backup file name is the backup slug).

* Check passed file name using regex

* Use custom filename on download only if backup file name is backup slug

* ruff format

* Remove path from location for download file name
This commit is contained in:
Stefan Agner 2025-01-23 15:24:47 +01:00 committed by GitHub
parent a545b680b3
commit 088832c253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 41 additions and 5 deletions

View File

@ -26,6 +26,7 @@ from ..const import (
ATTR_DATE,
ATTR_DAYS_UNTIL_STALE,
ATTR_EXTRA,
ATTR_FILENAME,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
@ -98,6 +99,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(ATTR_NAME): str,
vol.Optional(ATTR_FILENAME): vol.Match(RE_BACKUP_FILENAME),
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_LOCATION): vol.All(
@ -458,10 +460,15 @@ class APIBackups(CoreSysAttributes):
raise APIError(f"Backup {backup.slug} is not in location {location}")
_LOGGER.info("Downloading backup %s", backup.slug)
response = web.FileResponse(backup.all_locations[location])
filename = backup.all_locations[location]
response = web.FileResponse(filename)
response.content_type = CONTENT_TYPE_TAR
download_filename = filename.name
if download_filename == f"{backup.slug}.tar":
download_filename = f"{RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"
response.headers[CONTENT_DISPOSITION] = (
f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"
f"attachment; filename={download_filename}"
)
return response

View File

@ -184,6 +184,7 @@ class BackupManager(FileConfiguration, JobGroup):
def _create_backup(
self,
name: str,
filename: str | None,
sys_type: BackupType,
password: str | None,
compressed: bool = True,
@ -196,7 +197,11 @@ class BackupManager(FileConfiguration, JobGroup):
"""
date_str = utcnow().isoformat()
slug = create_slug(name, date_str)
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
if filename:
tar_file = Path(self._get_base_path(location), Path(filename).name)
else:
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
# init object
backup = Backup(self.coresys, tar_file, slug, self._get_location_name(location))
@ -482,6 +487,7 @@ class BackupManager(FileConfiguration, JobGroup):
async def do_backup_full(
self,
name: str = "",
filename: str | None = None,
*,
password: str | None = None,
compressed: bool = True,
@ -500,7 +506,7 @@ class BackupManager(FileConfiguration, JobGroup):
)
backup = self._create_backup(
name, BackupType.FULL, password, compressed, location, extra
name, filename, BackupType.FULL, password, compressed, location, extra
)
_LOGGER.info("Creating new full backup with slug %s", backup.slug)
@ -526,6 +532,7 @@ class BackupManager(FileConfiguration, JobGroup):
async def do_backup_partial(
self,
name: str = "",
filename: str | None = None,
*,
addons: list[str] | None = None,
folders: list[str] | None = None,
@ -558,7 +565,7 @@ class BackupManager(FileConfiguration, JobGroup):
_LOGGER.error("Nothing to create backup for")
backup = self._create_backup(
name, BackupType.PARTIAL, password, compressed, location, extra
name, filename, BackupType.PARTIAL, password, compressed, location, extra
)
_LOGGER.info("Creating new partial backup with slug %s", backup.slug)

View File

@ -73,6 +73,28 @@ async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
assert coresys.core.state == CoreState.RUNNING
@pytest.mark.parametrize(
("filename", "filename_expected"),
[("../my file.tar", "/data/backup/my file.tar"), (None, "/data/backup/{}.tar")],
)
async def test_do_backup_full_with_filename(
coresys: CoreSys, filename: str, filename_expected: str, backup_mock
):
"""Test creating Backup with a specific file name."""
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
await manager.do_backup_full(filename=filename)
slug = backup_mock.call_args[0][2]
assert str(backup_mock.call_args[0][1]) == filename_expected.format(slug)
assert coresys.core.state == CoreState.RUNNING
async def test_do_backup_full_uncompressed(
coresys: CoreSys, backup_mock, install_addon_ssh
):