Remove WebDAV properties and rely on metadata file (#140539)

This commit is contained in:
Jan-Philipp Benecke 2025-03-14 10:21:16 +01:00 committed by GitHub
parent d952e8186f
commit 99b140f73f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 48 additions and 123 deletions

View File

@ -5,10 +5,10 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Coroutine from collections.abc import AsyncIterator, Callable, Coroutine
from functools import wraps from functools import wraps
import logging import logging
from time import time
from typing import Any, Concatenate from typing import Any, Concatenate
from aiohttp import ClientTimeout from aiohttp import ClientTimeout
from aiowebdav2 import Property, PropertyRequest
from aiowebdav2.exceptions import UnauthorizedError, WebDavError from aiowebdav2.exceptions import UnauthorizedError, WebDavError
from propcache.api import cached_property from propcache.api import cached_property
@ -28,9 +28,8 @@ from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
METADATA_VERSION = "1"
BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200)
NAMESPACE = "https://home-assistant.io" CACHE_TTL = 300
async def async_get_backup_agents( async def async_get_backup_agents(
@ -96,23 +95,6 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
return f"{base_name}.tar", f"{base_name}.metadata.json" return f"{base_name}.tar", f"{base_name}.metadata.json"
def _is_current_metadata_version(properties: list[Property]) -> bool:
"""Check if any property is of the current metadata version."""
return any(
prop.value == METADATA_VERSION
for prop in properties
if prop.namespace == NAMESPACE and prop.name == "metadata_version"
)
def _backup_id_from_properties(properties: list[Property]) -> str | None:
"""Return the backup ID from properties."""
for prop in properties:
if prop.namespace == NAMESPACE and prop.name == "backup_id":
return prop.value
return None
class WebDavBackupAgent(BackupAgent): class WebDavBackupAgent(BackupAgent):
"""Backup agent interface.""" """Backup agent interface."""
@ -126,6 +108,8 @@ class WebDavBackupAgent(BackupAgent):
self._client = entry.runtime_data self._client = entry.runtime_data
self.name = entry.title self.name = entry.title
self.unique_id = entry.entry_id self.unique_id = entry.entry_id
self._cache_metadata_files: dict[str, AgentBackup] = {}
self._cache_expiration = time()
@cached_property @cached_property
def _backup_path(self) -> str: def _backup_path(self) -> str:
@ -182,27 +166,14 @@ class WebDavBackupAgent(BackupAgent):
f"{self._backup_path}/{filename_meta}", f"{self._backup_path}/{filename_meta}",
) )
await self._client.set_property_batch(
f"{self._backup_path}/{filename_meta}",
[
Property(
namespace=NAMESPACE,
name="backup_id",
value=backup.backup_id,
),
Property(
namespace=NAMESPACE,
name="metadata_version",
value=METADATA_VERSION,
),
],
)
_LOGGER.debug( _LOGGER.debug(
"Uploaded metadata file for %s", "Uploaded metadata file for %s",
f"{self._backup_path}/{filename_meta}", f"{self._backup_path}/{filename_meta}",
) )
# reset cache
self._cache_expiration = time()
@handle_backup_errors @handle_backup_errors
async def async_delete_backup( async def async_delete_backup(
self, self,
@ -226,14 +197,13 @@ class WebDavBackupAgent(BackupAgent):
backup_path, backup_path,
) )
# reset cache
self._cache_expiration = time()
@handle_backup_errors @handle_backup_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups.""" """List backups."""
metadata_files = await self._list_metadata_files() return list((await self._list_cached_metadata_files()).values())
return [
await self._download_metadata(metadata_file)
for metadata_file in metadata_files.values()
]
@handle_backup_errors @handle_backup_errors
async def async_get_backup( async def async_get_backup(
@ -244,38 +214,35 @@ class WebDavBackupAgent(BackupAgent):
"""Return a backup.""" """Return a backup."""
return await self._find_backup_by_id(backup_id) return await self._find_backup_by_id(backup_id)
async def _list_metadata_files(self) -> dict[str, str]: async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]:
"""List metadata files.""" """List metadata files with a cache."""
files = await self._client.list_with_properties( if time() <= self._cache_expiration:
self._backup_path, return self._cache_metadata_files
[
PropertyRequest( async def _download_metadata(path: str) -> AgentBackup:
namespace=NAMESPACE, """Download metadata file."""
name="metadata_version", iterator = await self._client.download_iter(path)
), metadata = await anext(iterator)
PropertyRequest( return AgentBackup.from_dict(json_loads_object(metadata))
namespace=NAMESPACE,
name="backup_id", async def _list_metadata_files() -> dict[str, AgentBackup]:
), """List metadata files."""
], files = await self._client.list_files(self._backup_path)
) return {
return { metadata_content.backup_id: metadata_content
backup_id: file_name for file_name in files
for file_name, properties in files.items() if file_name.endswith(".json")
if file_name.endswith(".json") and _is_current_metadata_version(properties) if (metadata_content := await _download_metadata(file_name))
if (backup_id := _backup_id_from_properties(properties)) }
}
self._cache_metadata_files = await _list_metadata_files()
self._cache_expiration = time() + CACHE_TTL
return self._cache_metadata_files
async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: async def _find_backup_by_id(self, backup_id: str) -> AgentBackup:
"""Find a backup by its backup ID on remote.""" """Find a backup by its backup ID on remote."""
metadata_files = await self._list_metadata_files() metadata_files = await self._list_cached_metadata_files()
if metadata_file := metadata_files.get(backup_id): if metadata_file := metadata_files.get(backup_id):
return await self._download_metadata(metadata_file) return metadata_file
raise BackupNotFound(f"Backup {backup_id} not found") raise BackupNotFound(f"Backup {backup_id} not found")
async def _download_metadata(self, path: str) -> AgentBackup:
"""Download metadata file."""
iterator = await self._client.download_iter(path)
metadata = await anext(iterator)
return AgentBackup.from_dict(json_loads_object(metadata))

View File

@ -9,7 +9,7 @@ import pytest
from homeassistant.components.webdav.const import DOMAIN from homeassistant.components.webdav.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from .const import BACKUP_METADATA, MOCK_LIST_FILES
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -58,7 +58,7 @@ def mock_webdav_client() -> Generator[AsyncMock]:
mock = mock_webdav_client.return_value mock = mock_webdav_client.return_value
mock.check.return_value = True mock.check.return_value = True
mock.mkdir.return_value = True mock.mkdir.return_value = True
mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES mock.list_files.return_value = MOCK_LIST_FILES
mock.download_iter.side_effect = _download_mock mock.download_iter.side_effect = _download_mock
mock.upload_iter.return_value = None mock.upload_iter.return_value = None
mock.clean.return_value = None mock.clean.return_value = None

View File

@ -1,7 +1,5 @@
"""Constants for WebDAV tests.""" """Constants for WebDAV tests."""
from aiowebdav2 import Property
BACKUP_METADATA = { BACKUP_METADATA = {
"addons": [], "addons": [],
"backup_id": "23e64aec", "backup_id": "23e64aec",
@ -16,18 +14,7 @@ BACKUP_METADATA = {
"size": 34519040, "size": 34519040,
} }
MOCK_LIST_WITH_PROPERTIES = { MOCK_LIST_FILES = [
"/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar",
"/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json",
Property( ]
namespace="https://home-assistant.io",
name="backup_id",
value="23e64aec",
),
Property(
namespace="https://home-assistant.io",
name="metadata_version",
value="1",
),
],
}

View File

@ -6,7 +6,6 @@ from collections.abc import AsyncGenerator
from io import StringIO from io import StringIO
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from aiowebdav2 import Property
from aiowebdav2.exceptions import UnauthorizedError, WebDavError from aiowebdav2.exceptions import UnauthorizedError, WebDavError
import pytest import pytest
@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.backup import async_initialize_backup
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from .const import BACKUP_METADATA
from tests.common import AsyncMock, MockConfigEntry from tests.common import AsyncMock, MockConfigEntry
from tests.typing import ClientSessionGenerator, WebSocketGenerator from tests.typing import ClientSessionGenerator, WebSocketGenerator
@ -184,7 +183,6 @@ async def test_agents_upload(
assert resp.status == 201 assert resp.status == 201
assert webdav_client.upload_iter.call_count == 2 assert webdav_client.upload_iter.call_count == 2
assert webdav_client.set_property_batch.call_count == 1
async def test_agents_download( async def test_agents_download(
@ -211,7 +209,7 @@ async def test_error_on_agents_download(
"""Test we get not found on a not existing backup on download.""" """Test we get not found on a not existing backup on download."""
client = await hass_client() client = await hass_client()
backup_id = BACKUP_METADATA["backup_id"] backup_id = BACKUP_METADATA["backup_id"]
webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] webdav_client.list_files.return_value = []
resp = await client.get( resp = await client.get(
f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}"
@ -262,7 +260,7 @@ async def test_agents_delete_not_found_does_not_throw(
webdav_client: AsyncMock, webdav_client: AsyncMock,
) -> None: ) -> None:
"""Test agent delete backup.""" """Test agent delete backup."""
webdav_client.list_with_properties.return_value = {} webdav_client.list_files.return_value = {}
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json_auto_id( await client.send_json_auto_id(
@ -283,7 +281,7 @@ async def test_agents_backup_not_found(
webdav_client: AsyncMock, webdav_client: AsyncMock,
) -> None: ) -> None:
"""Test backup not found.""" """Test backup not found."""
webdav_client.list_with_properties.return_value = [] webdav_client.list_files.return_value = []
backup_id = BACKUP_METADATA["backup_id"] backup_id = BACKUP_METADATA["backup_id"]
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id})
@ -300,7 +298,7 @@ async def test_raises_on_403(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test we raise on 403.""" """Test we raise on 403."""
webdav_client.list_with_properties.side_effect = UnauthorizedError( webdav_client.list_files.side_effect = UnauthorizedError(
"https://webdav.example.com" "https://webdav.example.com"
) )
backup_id = BACKUP_METADATA["backup_id"] backup_id = BACKUP_METADATA["backup_id"]
@ -324,30 +322,3 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
remove_listener() remove_listener()
assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None
async def test_metadata_misses_backup_id(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
webdav_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test getting a backup when metadata has backup id property."""
MOCK_LIST_WITH_PROPERTIES[
"/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json"
] = [
Property(
namespace="homeassistant",
name="metadata_version",
value="1",
)
]
webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES
backup_id = BACKUP_METADATA["backup_id"]
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id})
response = await client.receive_json()
assert response["success"]
assert response["result"]["backup"] is None