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 functools import wraps
import logging
from time import time
from typing import Any, Concatenate
from aiohttp import ClientTimeout
from aiowebdav2 import Property, PropertyRequest
from aiowebdav2.exceptions import UnauthorizedError, WebDavError
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__)
METADATA_VERSION = "1"
BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200)
NAMESPACE = "https://home-assistant.io"
CACHE_TTL = 300
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"
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):
"""Backup agent interface."""
@ -126,6 +108,8 @@ class WebDavBackupAgent(BackupAgent):
self._client = entry.runtime_data
self.name = entry.title
self.unique_id = entry.entry_id
self._cache_metadata_files: dict[str, AgentBackup] = {}
self._cache_expiration = time()
@cached_property
def _backup_path(self) -> str:
@ -182,27 +166,14 @@ class WebDavBackupAgent(BackupAgent):
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(
"Uploaded metadata file for %s",
f"{self._backup_path}/{filename_meta}",
)
# reset cache
self._cache_expiration = time()
@handle_backup_errors
async def async_delete_backup(
self,
@ -226,14 +197,13 @@ class WebDavBackupAgent(BackupAgent):
backup_path,
)
# reset cache
self._cache_expiration = time()
@handle_backup_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
metadata_files = await self._list_metadata_files()
return [
await self._download_metadata(metadata_file)
for metadata_file in metadata_files.values()
]
return list((await self._list_cached_metadata_files()).values())
@handle_backup_errors
async def async_get_backup(
@ -244,38 +214,35 @@ class WebDavBackupAgent(BackupAgent):
"""Return a backup."""
return await self._find_backup_by_id(backup_id)
async def _list_metadata_files(self) -> dict[str, str]:
"""List metadata files."""
files = await self._client.list_with_properties(
self._backup_path,
[
PropertyRequest(
namespace=NAMESPACE,
name="metadata_version",
),
PropertyRequest(
namespace=NAMESPACE,
name="backup_id",
),
],
)
return {
backup_id: file_name
for file_name, properties in files.items()
if file_name.endswith(".json") and _is_current_metadata_version(properties)
if (backup_id := _backup_id_from_properties(properties))
}
async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]:
"""List metadata files with a cache."""
if time() <= self._cache_expiration:
return self._cache_metadata_files
async def _download_metadata(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))
async def _list_metadata_files() -> dict[str, AgentBackup]:
"""List metadata files."""
files = await self._client.list_files(self._backup_path)
return {
metadata_content.backup_id: metadata_content
for file_name in files
if file_name.endswith(".json")
if (metadata_content := await _download_metadata(file_name))
}
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:
"""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):
return await self._download_metadata(metadata_file)
return metadata_file
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.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
@ -58,7 +58,7 @@ def mock_webdav_client() -> Generator[AsyncMock]:
mock = mock_webdav_client.return_value
mock.check.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.upload_iter.return_value = None
mock.clean.return_value = None

View File

@ -1,7 +1,5 @@
"""Constants for WebDAV tests."""
from aiowebdav2 import Property
BACKUP_METADATA = {
"addons": [],
"backup_id": "23e64aec",
@ -16,18 +14,7 @@ BACKUP_METADATA = {
"size": 34519040,
}
MOCK_LIST_WITH_PROPERTIES = {
"/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": [
Property(
namespace="https://home-assistant.io",
name="backup_id",
value="23e64aec",
),
Property(
namespace="https://home-assistant.io",
name="metadata_version",
value="1",
),
],
}
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.metadata.json",
]

View File

@ -6,7 +6,6 @@ from collections.abc import AsyncGenerator
from io import StringIO
from unittest.mock import Mock, patch
from aiowebdav2 import Property
from aiowebdav2.exceptions import UnauthorizedError, WebDavError
import pytest
@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.backup import async_initialize_backup
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.typing import ClientSessionGenerator, WebSocketGenerator
@ -184,7 +183,6 @@ async def test_agents_upload(
assert resp.status == 201
assert webdav_client.upload_iter.call_count == 2
assert webdav_client.set_property_batch.call_count == 1
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."""
client = await hass_client()
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(
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,
) -> None:
"""Test agent delete backup."""
webdav_client.list_with_properties.return_value = {}
webdav_client.list_files.return_value = {}
client = await hass_ws_client(hass)
await client.send_json_auto_id(
@ -283,7 +281,7 @@ async def test_agents_backup_not_found(
webdav_client: AsyncMock,
) -> None:
"""Test backup not found."""
webdav_client.list_with_properties.return_value = []
webdav_client.list_files.return_value = []
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})
@ -300,7 +298,7 @@ async def test_raises_on_403(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we raise on 403."""
webdav_client.list_with_properties.side_effect = UnauthorizedError(
webdav_client.list_files.side_effect = UnauthorizedError(
"https://webdav.example.com"
)
backup_id = BACKUP_METADATA["backup_id"]
@ -324,30 +322,3 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
remove_listener()
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