Skip to content

Commit 088832c

Browse files
authored
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
1 parent a545b68 commit 088832c

File tree

3 files changed

+41
-5
lines changed

3 files changed

+41
-5
lines changed

supervisor/api/backups.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
ATTR_DATE,
2727
ATTR_DAYS_UNTIL_STALE,
2828
ATTR_EXTRA,
29+
ATTR_FILENAME,
2930
ATTR_FOLDERS,
3031
ATTR_HOMEASSISTANT,
3132
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
@@ -98,6 +99,7 @@ def _ensure_list(item: Any) -> list:
9899
SCHEMA_BACKUP_FULL = vol.Schema(
99100
{
100101
vol.Optional(ATTR_NAME): str,
102+
vol.Optional(ATTR_FILENAME): vol.Match(RE_BACKUP_FILENAME),
101103
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
102104
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
103105
vol.Optional(ATTR_LOCATION): vol.All(
@@ -458,10 +460,15 @@ async def download(self, request: web.Request):
458460
raise APIError(f"Backup {backup.slug} is not in location {location}")
459461

460462
_LOGGER.info("Downloading backup %s", backup.slug)
461-
response = web.FileResponse(backup.all_locations[location])
463+
filename = backup.all_locations[location]
464+
response = web.FileResponse(filename)
462465
response.content_type = CONTENT_TYPE_TAR
466+
467+
download_filename = filename.name
468+
if download_filename == f"{backup.slug}.tar":
469+
download_filename = f"{RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"
463470
response.headers[CONTENT_DISPOSITION] = (
464-
f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"
471+
f"attachment; filename={download_filename}"
465472
)
466473
return response
467474

supervisor/backups/manager.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def _list_backup_files(self, path: Path) -> Iterable[Path]:
184184
def _create_backup(
185185
self,
186186
name: str,
187+
filename: str | None,
187188
sys_type: BackupType,
188189
password: str | None,
189190
compressed: bool = True,
@@ -196,7 +197,11 @@ def _create_backup(
196197
"""
197198
date_str = utcnow().isoformat()
198199
slug = create_slug(name, date_str)
199-
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
200+
201+
if filename:
202+
tar_file = Path(self._get_base_path(location), Path(filename).name)
203+
else:
204+
tar_file = Path(self._get_base_path(location), f"{slug}.tar")
200205

201206
# init object
202207
backup = Backup(self.coresys, tar_file, slug, self._get_location_name(location))
@@ -482,6 +487,7 @@ async def _do_backup(
482487
async def do_backup_full(
483488
self,
484489
name: str = "",
490+
filename: str | None = None,
485491
*,
486492
password: str | None = None,
487493
compressed: bool = True,
@@ -500,7 +506,7 @@ async def do_backup_full(
500506
)
501507

502508
backup = self._create_backup(
503-
name, BackupType.FULL, password, compressed, location, extra
509+
name, filename, BackupType.FULL, password, compressed, location, extra
504510
)
505511

506512
_LOGGER.info("Creating new full backup with slug %s", backup.slug)
@@ -526,6 +532,7 @@ async def do_backup_full(
526532
async def do_backup_partial(
527533
self,
528534
name: str = "",
535+
filename: str | None = None,
529536
*,
530537
addons: list[str] | None = None,
531538
folders: list[str] | None = None,
@@ -558,7 +565,7 @@ async def do_backup_partial(
558565
_LOGGER.error("Nothing to create backup for")
559566

560567
backup = self._create_backup(
561-
name, BackupType.PARTIAL, password, compressed, location, extra
568+
name, filename, BackupType.PARTIAL, password, compressed, location, extra
562569
)
563570

564571
_LOGGER.info("Creating new partial backup with slug %s", backup.slug)

tests/backups/test_manager.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
7373
assert coresys.core.state == CoreState.RUNNING
7474

7575

76+
@pytest.mark.parametrize(
77+
("filename", "filename_expected"),
78+
[("../my file.tar", "/data/backup/my file.tar"), (None, "/data/backup/{}.tar")],
79+
)
80+
async def test_do_backup_full_with_filename(
81+
coresys: CoreSys, filename: str, filename_expected: str, backup_mock
82+
):
83+
"""Test creating Backup with a specific file name."""
84+
coresys.core.state = CoreState.RUNNING
85+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
86+
87+
manager = BackupManager(coresys)
88+
89+
# backup_mock fixture causes Backup() to be a MagicMock
90+
await manager.do_backup_full(filename=filename)
91+
92+
slug = backup_mock.call_args[0][2]
93+
assert str(backup_mock.call_args[0][1]) == filename_expected.format(slug)
94+
95+
assert coresys.core.state == CoreState.RUNNING
96+
97+
7698
async def test_do_backup_full_uncompressed(
7799
coresys: CoreSys, backup_mock, install_addon_ssh
78100
):

0 commit comments

Comments
 (0)