Skip to content

Commit dc92e91

Browse files
Add azure_storage as backup agent (#134085)
Co-authored-by: Martin Hjelmare <[email protected]>
1 parent 2451e55 commit dc92e91

21 files changed

+1169
-0
lines changed

Diff for: .strict-typing

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ homeassistant.components.auth.*
103103
homeassistant.components.automation.*
104104
homeassistant.components.awair.*
105105
homeassistant.components.axis.*
106+
homeassistant.components.azure_storage.*
106107
homeassistant.components.backup.*
107108
homeassistant.components.baf.*
108109
homeassistant.components.bang_olufsen.*

Diff for: CODEOWNERS

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: homeassistant/brands/microsoft.json

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"azure_devops",
77
"azure_event_hub",
88
"azure_service_bus",
9+
"azure_storage",
910
"microsoft_face_detect",
1011
"microsoft_face_identify",
1112
"microsoft_face",

Diff for: homeassistant/components/azure_storage/__init__.py

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""The Azure Storage integration."""
2+
3+
from aiohttp import ClientTimeout
4+
from azure.core.exceptions import (
5+
ClientAuthenticationError,
6+
HttpResponseError,
7+
ResourceNotFoundError,
8+
)
9+
from azure.core.pipeline.transport._aiohttp import (
10+
AioHttpTransport,
11+
) # need to import from private file, as it is not properly imported in the init
12+
from azure.storage.blob.aio import ContainerClient
13+
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.core import HomeAssistant
16+
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
17+
from homeassistant.helpers.aiohttp_client import async_create_clientsession
18+
19+
from .const import (
20+
CONF_ACCOUNT_NAME,
21+
CONF_CONTAINER_NAME,
22+
CONF_STORAGE_ACCOUNT_KEY,
23+
DATA_BACKUP_AGENT_LISTENERS,
24+
DOMAIN,
25+
)
26+
27+
type AzureStorageConfigEntry = ConfigEntry[ContainerClient]
28+
29+
30+
async def async_setup_entry(
31+
hass: HomeAssistant, entry: AzureStorageConfigEntry
32+
) -> bool:
33+
"""Set up Azure Storage integration."""
34+
# set increase aiohttp timeout for long running operations (up/download)
35+
session = async_create_clientsession(
36+
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
37+
)
38+
container_client = ContainerClient(
39+
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
40+
container_name=entry.data[CONF_CONTAINER_NAME],
41+
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
42+
transport=AioHttpTransport(session=session),
43+
)
44+
45+
try:
46+
if not await container_client.exists():
47+
await container_client.create_container()
48+
except ResourceNotFoundError as err:
49+
raise ConfigEntryError(
50+
translation_domain=DOMAIN,
51+
translation_key="account_not_found",
52+
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
53+
) from err
54+
except ClientAuthenticationError as err:
55+
raise ConfigEntryError(
56+
translation_domain=DOMAIN,
57+
translation_key="invalid_auth",
58+
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
59+
) from err
60+
except HttpResponseError as err:
61+
raise ConfigEntryNotReady(
62+
translation_domain=DOMAIN,
63+
translation_key="cannot_connect",
64+
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
65+
) from err
66+
67+
entry.runtime_data = container_client
68+
69+
def _async_notify_backup_listeners() -> None:
70+
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
71+
listener()
72+
73+
entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners))
74+
75+
return True
76+
77+
78+
async def async_unload_entry(
79+
hass: HomeAssistant, entry: AzureStorageConfigEntry
80+
) -> bool:
81+
"""Unload an Azure Storage config entry."""
82+
return True

Diff for: homeassistant/components/azure_storage/backup.py

+182
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Support for Azure Storage backup."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import AsyncIterator, Callable, Coroutine
6+
from functools import wraps
7+
import json
8+
import logging
9+
from typing import Any, Concatenate
10+
11+
from azure.core.exceptions import HttpResponseError
12+
from azure.storage.blob import BlobProperties
13+
14+
from homeassistant.components.backup import (
15+
AgentBackup,
16+
BackupAgent,
17+
BackupAgentError,
18+
BackupNotFound,
19+
suggested_filename,
20+
)
21+
from homeassistant.core import HomeAssistant, callback
22+
23+
from . import AzureStorageConfigEntry
24+
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
25+
26+
_LOGGER = logging.getLogger(__name__)
27+
METADATA_VERSION = "1"
28+
29+
30+
async def async_get_backup_agents(
31+
hass: HomeAssistant,
32+
) -> list[BackupAgent]:
33+
"""Return a list of backup agents."""
34+
entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries(
35+
DOMAIN
36+
)
37+
return [AzureStorageBackupAgent(hass, entry) for entry in entries]
38+
39+
40+
@callback
41+
def async_register_backup_agents_listener(
42+
hass: HomeAssistant,
43+
*,
44+
listener: Callable[[], None],
45+
**kwargs: Any,
46+
) -> Callable[[], None]:
47+
"""Register a listener to be called when agents are added or removed."""
48+
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
49+
50+
@callback
51+
def remove_listener() -> None:
52+
"""Remove the listener."""
53+
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
54+
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
55+
hass.data.pop(DATA_BACKUP_AGENT_LISTENERS)
56+
57+
return remove_listener
58+
59+
60+
def handle_backup_errors[_R, **P](
61+
func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]],
62+
) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]:
63+
"""Handle backup errors."""
64+
65+
@wraps(func)
66+
async def wrapper(
67+
self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs
68+
) -> _R:
69+
try:
70+
return await func(self, *args, **kwargs)
71+
except HttpResponseError as err:
72+
_LOGGER.debug(
73+
"Error during backup in %s: Status %s, message %s",
74+
func.__name__,
75+
err.status_code,
76+
err.message,
77+
exc_info=True,
78+
)
79+
raise BackupAgentError(
80+
f"Error during backup operation in {func.__name__}:"
81+
f" Status {err.status_code}, message: {err.message}"
82+
) from err
83+
84+
return wrapper
85+
86+
87+
class AzureStorageBackupAgent(BackupAgent):
88+
"""Azure storage backup agent."""
89+
90+
domain = DOMAIN
91+
92+
def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None:
93+
"""Initialize the Azure storage backup agent."""
94+
super().__init__()
95+
self._client = entry.runtime_data
96+
self.name = entry.title
97+
self.unique_id = entry.entry_id
98+
99+
@handle_backup_errors
100+
async def async_download_backup(
101+
self,
102+
backup_id: str,
103+
**kwargs: Any,
104+
) -> AsyncIterator[bytes]:
105+
"""Download a backup file."""
106+
blob = await self._find_blob_by_backup_id(backup_id)
107+
if blob is None:
108+
raise BackupNotFound(f"Backup {backup_id} not found")
109+
download_stream = await self._client.download_blob(blob.name)
110+
return download_stream.chunks()
111+
112+
@handle_backup_errors
113+
async def async_upload_backup(
114+
self,
115+
*,
116+
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
117+
backup: AgentBackup,
118+
**kwargs: Any,
119+
) -> None:
120+
"""Upload a backup."""
121+
122+
metadata = {
123+
"metadata_version": METADATA_VERSION,
124+
"backup_id": backup.backup_id,
125+
"backup_metadata": json.dumps(backup.as_dict()),
126+
}
127+
128+
await self._client.upload_blob(
129+
name=suggested_filename(backup),
130+
metadata=metadata,
131+
data=await open_stream(),
132+
length=backup.size,
133+
)
134+
135+
@handle_backup_errors
136+
async def async_delete_backup(
137+
self,
138+
backup_id: str,
139+
**kwargs: Any,
140+
) -> None:
141+
"""Delete a backup file."""
142+
blob = await self._find_blob_by_backup_id(backup_id)
143+
if blob is None:
144+
return
145+
await self._client.delete_blob(blob.name)
146+
147+
@handle_backup_errors
148+
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
149+
"""List backups."""
150+
backups: list[AgentBackup] = []
151+
async for blob in self._client.list_blobs(include="metadata"):
152+
metadata = blob.metadata
153+
154+
if metadata.get("metadata_version") == METADATA_VERSION:
155+
backups.append(
156+
AgentBackup.from_dict(json.loads(metadata["backup_metadata"]))
157+
)
158+
159+
return backups
160+
161+
@handle_backup_errors
162+
async def async_get_backup(
163+
self,
164+
backup_id: str,
165+
**kwargs: Any,
166+
) -> AgentBackup | None:
167+
"""Return a backup."""
168+
blob = await self._find_blob_by_backup_id(backup_id)
169+
if blob is None:
170+
return None
171+
172+
return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
173+
174+
async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None:
175+
"""Find a blob by backup id."""
176+
async for blob in self._client.list_blobs(include="metadata"):
177+
if (
178+
backup_id == blob.metadata.get("backup_id", "")
179+
and blob.metadata.get("metadata_version") == METADATA_VERSION
180+
):
181+
return blob
182+
return None
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Config flow for Azure Storage integration."""
2+
3+
import logging
4+
from typing import Any
5+
6+
from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
7+
from azure.core.pipeline.transport._aiohttp import (
8+
AioHttpTransport,
9+
) # need to import from private file, as it is not properly imported in the init
10+
from azure.storage.blob.aio import ContainerClient
11+
import voluptuous as vol
12+
13+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
14+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
15+
16+
from .const import (
17+
CONF_ACCOUNT_NAME,
18+
CONF_CONTAINER_NAME,
19+
CONF_STORAGE_ACCOUNT_KEY,
20+
DOMAIN,
21+
)
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
27+
"""Handle a config flow for azure storage."""
28+
29+
async def async_step_user(
30+
self, user_input: dict[str, Any] | None = None
31+
) -> ConfigFlowResult:
32+
"""User step for Azure Storage."""
33+
34+
errors: dict[str, str] = {}
35+
36+
if user_input is not None:
37+
self._async_abort_entries_match(
38+
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
39+
)
40+
container_client = ContainerClient(
41+
account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
42+
container_name=user_input[CONF_CONTAINER_NAME],
43+
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
44+
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
45+
)
46+
try:
47+
await container_client.exists()
48+
except ResourceNotFoundError:
49+
errors["base"] = "cannot_connect"
50+
except ClientAuthenticationError:
51+
errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
52+
except Exception:
53+
_LOGGER.exception("Unknown exception occurred")
54+
errors["base"] = "unknown"
55+
if not errors:
56+
return self.async_create_entry(
57+
title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}",
58+
data=user_input,
59+
)
60+
61+
return self.async_show_form(
62+
data_schema=vol.Schema(
63+
{
64+
vol.Required(CONF_ACCOUNT_NAME): str,
65+
vol.Required(
66+
CONF_CONTAINER_NAME, default="home-assistant-backups"
67+
): str,
68+
vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
69+
}
70+
),
71+
errors=errors,
72+
)

Diff for: homeassistant/components/azure_storage/const.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Constants for the Azure Storage integration."""
2+
3+
from collections.abc import Callable
4+
from typing import Final
5+
6+
from homeassistant.util.hass_dict import HassKey
7+
8+
DOMAIN: Final = "azure_storage"
9+
10+
CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key"
11+
CONF_ACCOUNT_NAME: Final = "account_name"
12+
CONF_CONTAINER_NAME: Final = "container_name"
13+
14+
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
15+
f"{DOMAIN}.backup_agent_listeners"
16+
)

Diff for: homeassistant/components/azure_storage/manifest.json

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"domain": "azure_storage",
3+
"name": "Azure Storage",
4+
"codeowners": ["@zweckj"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/azure_storage",
7+
"integration_type": "service",
8+
"iot_class": "cloud_polling",
9+
"loggers": ["azure-storage-blob"],
10+
"quality_scale": "bronze",
11+
"requirements": ["azure-storage-blob==12.24.0"]
12+
}

0 commit comments

Comments
 (0)