Skip to content

Commit

Permalink
improve backup file naming
Browse files Browse the repository at this point in the history
  • Loading branch information
mib1185 committed Feb 3, 2025
1 parent 775935f commit a553631
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 24 deletions.
44 changes: 40 additions & 4 deletions homeassistant/components/synology_dsm/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from homeassistant.helpers.json import json_dumps
from homeassistant.util.dt import parse_datetime
from homeassistant.util.json import JsonObjectType, json_loads_object

from .const import (
Expand All @@ -28,6 +29,16 @@
LOGGER = logging.getLogger(__name__)


def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Suggest filenames for the backup.
returns a tuple of tar_filename and meta_filename
"""
date = parse_datetime(backup.date, raise_on_error=True)
base_name = "_".join(f"{backup.name}-{date.strftime('%Y-%m-%d_%H-%M-%S')}".split())
return (f"{base_name}.tar", f"{base_name}_meta.json")


async def async_get_backup_agents(
hass: HomeAssistant,
) -> list[BackupAgent]:
Expand Down Expand Up @@ -95,6 +106,19 @@ def _file_station(self) -> SynoFileStation:
assert self.api.file_station
return self.api.file_station

async def _async_suggested_filenames(
self,
backup_id: str,
) -> tuple[str, str]:
"""Suggest filenames for the backup.
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: A tuple of tar_filename and meta_filename
"""
if (backup := await self.async_get_backup(backup_id)) is None:
raise BackupAgentError("Backup not found")
return suggested_filenames(backup)

async def async_download_backup(
self,
backup_id: str,
Expand All @@ -105,10 +129,12 @@ async def async_download_backup(
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
(filename_tar, _) = await self._async_suggested_filenames(backup_id)

try:
resp = await self._file_station.download_file(
path=self.path,
filename=f"{backup_id}.tar",
filename=filename_tar,
)
except SynologyDSMAPIErrorException as err:
raise BackupAgentError("Failed to download backup") from err
Expand All @@ -131,11 +157,13 @@ async def async_upload_backup(
:param backup: Metadata about the backup that should be uploaded.
"""

(filename_tar, filename_meta) = suggested_filenames(backup)

# upload backup.tar file first
try:
await self._file_station.upload_file(
path=self.path,
filename=f"{backup.backup_id}.tar",
filename=filename_tar,
source=await open_stream(),
create_parents=True,
)
Expand All @@ -146,7 +174,7 @@ async def async_upload_backup(
try:
await self._file_station.upload_file(
path=self.path,
filename=f"{backup.backup_id}_meta.json",
filename=filename_meta,
source=json_dumps(backup.as_dict()).encode(),
)
except SynologyDSMAPIErrorException as err:
Expand All @@ -161,7 +189,15 @@ async def async_delete_backup(
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
for filename in (f"{backup_id}.tar", f"{backup_id}_meta.json"):
try:
(filename_tar, filename_meta) = await self._async_suggested_filenames(
backup_id
)
except BackupAgentError:
# backup meta data could not be found, so we can't delete the backup
return

for filename in (filename_tar, filename_meta):
try:
await self._file_station.delete_file(path=self.path, filename=filename)
except SynologyDSMAPIErrorException as err:
Expand Down
46 changes: 26 additions & 20 deletions tests/components/synology_dsm/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
from tests.common import MockConfigEntry
from tests.typing import ClientSessionGenerator, WebSocketGenerator

BASE_FILENAME = "Automatic_backup_2025.2.0.dev0-2025-01-09_20-14-35"


class MockStreamReaderChunked(MockStreamReader):
"""Mock a stream reader with simulated chunked data."""
Expand All @@ -46,45 +48,44 @@ async def readchunk(self) -> tuple[bytes, bool]:


async def _mock_download_file(path: str, filename: str) -> MockStreamReader:
if filename == "abcd12ef_meta.json":
if filename == f"{BASE_FILENAME}_meta.json":
return MockStreamReader(
b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",'
b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",'
b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,'
b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}'
)
if filename == "abcd12ef.tar":
if filename == f"{BASE_FILENAME}.tar":
return MockStreamReaderChunked(b"backup data")
raise MockStreamReaderChunked(b"")


async def _mock_download_file_meta_ok_tar_missing(
path: str, filename: str
) -> MockStreamReader:
if filename == "abcd12ef_meta.json":
if filename == f"{BASE_FILENAME}_meta.json":
return MockStreamReader(
b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",'
b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",'
b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,'
b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}'
)
if filename == "abcd12ef.tar":
raise SynologyDSMAPIErrorException("api", "404", "not found")
if filename == f"{BASE_FILENAME}.tar":
raise SynologyDSMAPIErrorException("api", "900", [{"code": 408}])
raise MockStreamReaderChunked(b"")


async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader:
if filename == "abcd12ef_meta.json":
if filename == f"{BASE_FILENAME}_meta.json":
return MockStreamReader(b"im not a json")
if filename == "abcd12ef.tar":
if filename == f"{BASE_FILENAME}.tar":
return MockStreamReaderChunked(b"backup data")
raise MockStreamReaderChunked(b"")


@pytest.fixture
def mock_dsm_with_filestation():
"""Mock a successful service with filestation support."""

with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm:
dsm.login = AsyncMock(return_value=True)
dsm.update = AsyncMock(return_value=True)
Expand Down Expand Up @@ -115,14 +116,14 @@ def mock_dsm_with_filestation():
SynoFileFile(
additional=None,
is_dir=False,
name="abcd12ef_meta.json",
path="/ha_backup/my_backup_path/abcd12ef_meta.json",
name=f"{BASE_FILENAME}_meta.json",
path=f"/ha_backup/my_backup_path/{BASE_FILENAME}_meta.json",
),
SynoFileFile(
additional=None,
is_dir=False,
name="abcd12ef.tar",
path="/ha_backup/my_backup_path/abcd12ef.tar",
name=f"{BASE_FILENAME}.tar",
path=f"/ha_backup/my_backup_path/{BASE_FILENAME}.tar",
),
]
),
Expand Down Expand Up @@ -522,6 +523,7 @@ async def test_agents_upload(
protected=True,
size=0,
)
base_filename = "Test-1970-01-01_00-00-00"

with (
patch(
Expand All @@ -544,9 +546,9 @@ async def test_agents_upload(
assert f"Uploading backup {backup_id}" in caplog.text
mock: AsyncMock = setup_dsm_with_filestation.file.upload_file
assert len(mock.mock_calls) == 2
assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar"
assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar"
assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path"
assert mock.call_args_list[1].kwargs["filename"] == "test-backup_meta.json"
assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}_meta.json"
assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path"


Expand All @@ -572,6 +574,7 @@ async def test_agents_upload_error(
protected=True,
size=0,
)
base_filename = "Test-1970-01-01_00-00-00"

# fail to upload the tar file
with (
Expand Down Expand Up @@ -599,7 +602,7 @@ async def test_agents_upload_error(
assert "Failed to upload backup" in caplog.text
mock: AsyncMock = setup_dsm_with_filestation.file.upload_file
assert len(mock.mock_calls) == 1
assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar"
assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar"
assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path"

# fail to upload the meta json file
Expand Down Expand Up @@ -630,9 +633,9 @@ async def test_agents_upload_error(
assert "Failed to upload backup" in caplog.text
mock: AsyncMock = setup_dsm_with_filestation.file.upload_file
assert len(mock.mock_calls) == 3
assert mock.call_args_list[1].kwargs["filename"] == "test-backup.tar"
assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}.tar"
assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path"
assert mock.call_args_list[2].kwargs["filename"] == "test-backup_meta.json"
assert mock.call_args_list[2].kwargs["filename"] == f"{base_filename}_meta.json"
assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path"


Expand All @@ -657,9 +660,9 @@ async def test_agents_delete(
assert response["result"] == {"agent_errors": {}}
mock: AsyncMock = setup_dsm_with_filestation.file.delete_file
assert len(mock.mock_calls) == 2
assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar"
assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar"
assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path"
assert mock.call_args_list[1].kwargs["filename"] == "abcd12ef_meta.json"
assert mock.call_args_list[1].kwargs["filename"] == f"{BASE_FILENAME}_meta.json"
assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path"


Expand All @@ -672,6 +675,9 @@ async def test_agents_delete_not_existing(
client = await hass_ws_client(hass)
backup_id = "ef34ab12"

setup_dsm_with_filestation.file.download_file = (
_mock_download_file_meta_ok_tar_missing
)
setup_dsm_with_filestation.file.delete_file = AsyncMock(
side_effect=SynologyDSMAPIErrorException(
"api",
Expand Down Expand Up @@ -740,5 +746,5 @@ async def test_agents_delete_error(
assert f"Failed to delete backup: {expected_log}" in caplog.text
mock: AsyncMock = setup_dsm_with_filestation.file.delete_file
assert len(mock.mock_calls) == 1
assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar"
assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar"
assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path"

0 comments on commit a553631

Please sign in to comment.