Allow creating backup if at least one agent is available (#137409)
parent
c506c9080a
commit
30c099ef4e
|
@ -9,6 +9,7 @@ from dataclasses import dataclass, replace
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
from itertools import chain
|
||||||
import json
|
import json
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
import shutil
|
import shutil
|
||||||
|
@ -827,7 +828,7 @@ class BackupManager:
|
||||||
password=None,
|
password=None,
|
||||||
)
|
)
|
||||||
await written_backup.release_stream()
|
await written_backup.release_stream()
|
||||||
self.known_backups.add(written_backup.backup, agent_errors)
|
self.known_backups.add(written_backup.backup, agent_errors, [])
|
||||||
return written_backup.backup.backup_id
|
return written_backup.backup.backup_id
|
||||||
|
|
||||||
async def async_create_backup(
|
async def async_create_backup(
|
||||||
|
@ -951,12 +952,23 @@ class BackupManager:
|
||||||
with_automatic_settings: bool,
|
with_automatic_settings: bool,
|
||||||
) -> NewBackup:
|
) -> NewBackup:
|
||||||
"""Initiate generating a backup."""
|
"""Initiate generating a backup."""
|
||||||
if not agent_ids:
|
unavailable_agents = [
|
||||||
raise BackupManagerError("At least one agent must be selected")
|
|
||||||
if invalid_agents := [
|
|
||||||
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
|
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
|
||||||
]:
|
]
|
||||||
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}")
|
if not (
|
||||||
|
available_agents := [
|
||||||
|
agent_id for agent_id in agent_ids if agent_id in self.backup_agents
|
||||||
|
]
|
||||||
|
):
|
||||||
|
raise BackupManagerError(
|
||||||
|
f"At least one available backup agent must be selected, got {agent_ids}"
|
||||||
|
)
|
||||||
|
if unavailable_agents:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Backup agents %s are not available, will backupp to %s",
|
||||||
|
unavailable_agents,
|
||||||
|
available_agents,
|
||||||
|
)
|
||||||
if include_all_addons and include_addons:
|
if include_all_addons and include_addons:
|
||||||
raise BackupManagerError(
|
raise BackupManagerError(
|
||||||
"Cannot include all addons and specify specific addons"
|
"Cannot include all addons and specify specific addons"
|
||||||
|
@ -973,7 +985,7 @@ class BackupManager:
|
||||||
new_backup,
|
new_backup,
|
||||||
self._backup_task,
|
self._backup_task,
|
||||||
) = await self._reader_writer.async_create_backup(
|
) = await self._reader_writer.async_create_backup(
|
||||||
agent_ids=agent_ids,
|
agent_ids=available_agents,
|
||||||
backup_name=backup_name,
|
backup_name=backup_name,
|
||||||
extra_metadata=extra_metadata
|
extra_metadata=extra_metadata
|
||||||
| {
|
| {
|
||||||
|
@ -992,7 +1004,9 @@ class BackupManager:
|
||||||
raise BackupManagerError(str(err)) from err
|
raise BackupManagerError(str(err)) from err
|
||||||
|
|
||||||
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
|
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
|
||||||
self._async_finish_backup(agent_ids, with_automatic_settings, password),
|
self._async_finish_backup(
|
||||||
|
available_agents, unavailable_agents, with_automatic_settings, password
|
||||||
|
),
|
||||||
name="backup_manager_finish_backup",
|
name="backup_manager_finish_backup",
|
||||||
)
|
)
|
||||||
if not raise_task_error:
|
if not raise_task_error:
|
||||||
|
@ -1009,7 +1023,11 @@ class BackupManager:
|
||||||
return new_backup
|
return new_backup
|
||||||
|
|
||||||
async def _async_finish_backup(
|
async def _async_finish_backup(
|
||||||
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None
|
self,
|
||||||
|
available_agents: list[str],
|
||||||
|
unavailable_agents: list[str],
|
||||||
|
with_automatic_settings: bool,
|
||||||
|
password: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Finish a backup."""
|
"""Finish a backup."""
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -1028,7 +1046,7 @@ class BackupManager:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Generated new backup with backup_id %s, uploading to agents %s",
|
"Generated new backup with backup_id %s, uploading to agents %s",
|
||||||
written_backup.backup.backup_id,
|
written_backup.backup.backup_id,
|
||||||
agent_ids,
|
available_agents,
|
||||||
)
|
)
|
||||||
self.async_on_backup_event(
|
self.async_on_backup_event(
|
||||||
CreateBackupEvent(
|
CreateBackupEvent(
|
||||||
|
@ -1041,13 +1059,15 @@ class BackupManager:
|
||||||
try:
|
try:
|
||||||
agent_errors = await self._async_upload_backup(
|
agent_errors = await self._async_upload_backup(
|
||||||
backup=written_backup.backup,
|
backup=written_backup.backup,
|
||||||
agent_ids=agent_ids,
|
agent_ids=available_agents,
|
||||||
open_stream=written_backup.open_stream,
|
open_stream=written_backup.open_stream,
|
||||||
password=password,
|
password=password,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await written_backup.release_stream()
|
await written_backup.release_stream()
|
||||||
self.known_backups.add(written_backup.backup, agent_errors)
|
self.known_backups.add(
|
||||||
|
written_backup.backup, agent_errors, unavailable_agents
|
||||||
|
)
|
||||||
if not agent_errors:
|
if not agent_errors:
|
||||||
if with_automatic_settings:
|
if with_automatic_settings:
|
||||||
# create backup was successful, update last_completed_automatic_backup
|
# create backup was successful, update last_completed_automatic_backup
|
||||||
|
@ -1056,7 +1076,7 @@ class BackupManager:
|
||||||
backup_success = True
|
backup_success = True
|
||||||
|
|
||||||
if with_automatic_settings:
|
if with_automatic_settings:
|
||||||
self._update_issue_after_agent_upload(agent_errors)
|
self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
|
||||||
# delete old backups more numerous than copies
|
# delete old backups more numerous than copies
|
||||||
# try this regardless of agent errors above
|
# try this regardless of agent errors above
|
||||||
await delete_backups_exceeding_configured_count(self)
|
await delete_backups_exceeding_configured_count(self)
|
||||||
|
@ -1216,10 +1236,10 @@ class BackupManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_issue_after_agent_upload(
|
def _update_issue_after_agent_upload(
|
||||||
self, agent_errors: dict[str, Exception]
|
self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update issue registry after a backup is uploaded to agents."""
|
"""Update issue registry after a backup is uploaded to agents."""
|
||||||
if not agent_errors:
|
if not agent_errors and not unavailable_agents:
|
||||||
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
|
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
|
||||||
return
|
return
|
||||||
ir.async_create_issue(
|
ir.async_create_issue(
|
||||||
|
@ -1233,7 +1253,13 @@ class BackupManager:
|
||||||
translation_key="automatic_backup_failed_upload_agents",
|
translation_key="automatic_backup_failed_upload_agents",
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"failed_agents": ", ".join(
|
"failed_agents": ", ".join(
|
||||||
self.backup_agents[agent_id].name for agent_id in agent_errors
|
chain(
|
||||||
|
(
|
||||||
|
self.backup_agents[agent_id].name
|
||||||
|
for agent_id in agent_errors
|
||||||
|
),
|
||||||
|
unavailable_agents,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -1302,11 +1328,12 @@ class KnownBackups:
|
||||||
self,
|
self,
|
||||||
backup: AgentBackup,
|
backup: AgentBackup,
|
||||||
agent_errors: dict[str, Exception],
|
agent_errors: dict[str, Exception],
|
||||||
|
unavailable_agents: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a backup."""
|
"""Add a backup."""
|
||||||
self._backups[backup.backup_id] = KnownBackup(
|
self._backups[backup.backup_id] = KnownBackup(
|
||||||
backup_id=backup.backup_id,
|
backup_id=backup.backup_id,
|
||||||
failed_agent_ids=list(agent_errors),
|
failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
|
||||||
)
|
)
|
||||||
self._manager.store.save()
|
self._manager.store.save()
|
||||||
|
|
||||||
|
|
|
@ -359,8 +359,14 @@ async def test_create_backup_when_busy(
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("parameters", "expected_error"),
|
("parameters", "expected_error"),
|
||||||
[
|
[
|
||||||
({"agent_ids": []}, "At least one agent must be selected"),
|
(
|
||||||
({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"),
|
{"agent_ids": []},
|
||||||
|
"At least one available backup agent must be selected, got []",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"agent_ids": ["non_existing"]},
|
||||||
|
"At least one available backup agent must be selected, got ['non_existing']",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
{"include_addons": ["ssl"], "include_all_addons": True},
|
{"include_addons": ["ssl"], "include_all_addons": True},
|
||||||
"Cannot include all addons and specify specific addons",
|
"Cannot include all addons and specify specific addons",
|
||||||
|
@ -410,6 +416,8 @@ async def test_create_backup_wrong_parameters(
|
||||||
"name",
|
"name",
|
||||||
"expected_name",
|
"expected_name",
|
||||||
"expected_filename",
|
"expected_filename",
|
||||||
|
"expected_agent_ids",
|
||||||
|
"expected_failed_agent_ids",
|
||||||
"temp_file_unlink_call_count",
|
"temp_file_unlink_call_count",
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
|
@ -419,6 +427,8 @@ async def test_create_backup_wrong_parameters(
|
||||||
None,
|
None,
|
||||||
"Custom backup 2025.1.0",
|
"Custom backup 2025.1.0",
|
||||||
"Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar",
|
"Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar",
|
||||||
|
[LOCAL_AGENT_ID],
|
||||||
|
[],
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -427,6 +437,8 @@ async def test_create_backup_wrong_parameters(
|
||||||
None,
|
None,
|
||||||
"Custom backup 2025.1.0",
|
"Custom backup 2025.1.0",
|
||||||
"abc123.tar", # We don't use friendly name for temporary backups
|
"abc123.tar", # We don't use friendly name for temporary backups
|
||||||
|
["test.remote"],
|
||||||
|
[],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -435,6 +447,8 @@ async def test_create_backup_wrong_parameters(
|
||||||
None,
|
None,
|
||||||
"Custom backup 2025.1.0",
|
"Custom backup 2025.1.0",
|
||||||
"Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar",
|
"Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar",
|
||||||
|
[LOCAL_AGENT_ID, "test.remote"],
|
||||||
|
[],
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -443,6 +457,8 @@ async def test_create_backup_wrong_parameters(
|
||||||
"custom_name",
|
"custom_name",
|
||||||
"custom_name",
|
"custom_name",
|
||||||
"custom_name_-_2025-01-30_05.42_12345678.tar",
|
"custom_name_-_2025-01-30_05.42_12345678.tar",
|
||||||
|
[LOCAL_AGENT_ID],
|
||||||
|
[],
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -451,6 +467,8 @@ async def test_create_backup_wrong_parameters(
|
||||||
"custom_name",
|
"custom_name",
|
||||||
"custom_name",
|
"custom_name",
|
||||||
"abc123.tar", # We don't use friendly name for temporary backups
|
"abc123.tar", # We don't use friendly name for temporary backups
|
||||||
|
["test.remote"],
|
||||||
|
[],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -459,6 +477,19 @@ async def test_create_backup_wrong_parameters(
|
||||||
"custom_name",
|
"custom_name",
|
||||||
"custom_name",
|
"custom_name",
|
||||||
"custom_name_-_2025-01-30_05.42_12345678.tar",
|
"custom_name_-_2025-01-30_05.42_12345678.tar",
|
||||||
|
[LOCAL_AGENT_ID, "test.remote"],
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# Test we create a backup when at least one agent is available
|
||||||
|
[LOCAL_AGENT_ID, "test.unavailable"],
|
||||||
|
"backups",
|
||||||
|
"custom_name",
|
||||||
|
"custom_name",
|
||||||
|
"custom_name_-_2025-01-30_05.42_12345678.tar",
|
||||||
|
[LOCAL_AGENT_ID],
|
||||||
|
["test.unavailable"],
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -486,6 +517,8 @@ async def test_initiate_backup(
|
||||||
name: str | None,
|
name: str | None,
|
||||||
expected_name: str,
|
expected_name: str,
|
||||||
expected_filename: str,
|
expected_filename: str,
|
||||||
|
expected_agent_ids: list[str],
|
||||||
|
expected_failed_agent_ids: list[str],
|
||||||
temp_file_unlink_call_count: int,
|
temp_file_unlink_call_count: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test generate backup."""
|
"""Test generate backup."""
|
||||||
|
@ -620,13 +653,13 @@ async def test_initiate_backup(
|
||||||
"addons": [],
|
"addons": [],
|
||||||
"agents": {
|
"agents": {
|
||||||
agent_id: {"protected": bool(password), "size": ANY}
|
agent_id: {"protected": bool(password), "size": ANY}
|
||||||
for agent_id in agent_ids
|
for agent_id in expected_agent_ids
|
||||||
},
|
},
|
||||||
"backup_id": backup_id,
|
"backup_id": backup_id,
|
||||||
"database_included": include_database,
|
"database_included": include_database,
|
||||||
"date": ANY,
|
"date": ANY,
|
||||||
"extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False},
|
"extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False},
|
||||||
"failed_agent_ids": [],
|
"failed_agent_ids": expected_failed_agent_ids,
|
||||||
"folders": [],
|
"folders": [],
|
||||||
"homeassistant_included": True,
|
"homeassistant_included": True,
|
||||||
"homeassistant_version": "2025.1.0",
|
"homeassistant_version": "2025.1.0",
|
||||||
|
@ -959,6 +992,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
(
|
(
|
||||||
|
"automatic_agents",
|
||||||
"create_backup_command",
|
"create_backup_command",
|
||||||
"create_backup_side_effect",
|
"create_backup_side_effect",
|
||||||
"agent_upload_side_effect",
|
"agent_upload_side_effect",
|
||||||
|
@ -968,6 +1002,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
[
|
[
|
||||||
# No error
|
# No error
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
@ -975,14 +1010,38 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate_with_automatic_settings"},
|
{"type": "backup/generate_with_automatic_settings"},
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
True,
|
True,
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
|
# One agent unavailable
|
||||||
|
(
|
||||||
|
["test.remote", "test.unknown"],
|
||||||
|
{"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["test.remote", "test.unknown"],
|
||||||
|
{"type": "backup/generate_with_automatic_settings"},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
{
|
||||||
|
(DOMAIN, "automatic_backup_failed"): {
|
||||||
|
"translation_key": "automatic_backup_failed_upload_agents",
|
||||||
|
"translation_placeholders": {"failed_agents": "test.unknown"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
# Error raised in async_initiate_backup
|
# Error raised in async_initiate_backup
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
||||||
Exception("Boom!"),
|
Exception("Boom!"),
|
||||||
None,
|
None,
|
||||||
|
@ -990,6 +1049,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate_with_automatic_settings"},
|
{"type": "backup/generate_with_automatic_settings"},
|
||||||
Exception("Boom!"),
|
Exception("Boom!"),
|
||||||
None,
|
None,
|
||||||
|
@ -1003,6 +1063,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
),
|
),
|
||||||
# Error raised when awaiting the backup task
|
# Error raised when awaiting the backup task
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
||||||
delayed_boom,
|
delayed_boom,
|
||||||
None,
|
None,
|
||||||
|
@ -1010,6 +1071,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate_with_automatic_settings"},
|
{"type": "backup/generate_with_automatic_settings"},
|
||||||
delayed_boom,
|
delayed_boom,
|
||||||
None,
|
None,
|
||||||
|
@ -1023,6 +1085,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
),
|
),
|
||||||
# Error raised in async_upload_backup
|
# Error raised in async_upload_backup
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
{"type": "backup/generate", "agent_ids": ["test.remote"]},
|
||||||
None,
|
None,
|
||||||
Exception("Boom!"),
|
Exception("Boom!"),
|
||||||
|
@ -1030,6 +1093,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
["test.remote"],
|
||||||
{"type": "backup/generate_with_automatic_settings"},
|
{"type": "backup/generate_with_automatic_settings"},
|
||||||
None,
|
None,
|
||||||
Exception("Boom!"),
|
Exception("Boom!"),
|
||||||
|
@ -1047,6 +1111,7 @@ async def test_create_backup_failure_raises_issue(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
create_backup: AsyncMock,
|
create_backup: AsyncMock,
|
||||||
|
automatic_agents: list[str],
|
||||||
create_backup_command: dict[str, Any],
|
create_backup_command: dict[str, Any],
|
||||||
create_backup_side_effect: Exception | None,
|
create_backup_side_effect: Exception | None,
|
||||||
agent_upload_side_effect: Exception | None,
|
agent_upload_side_effect: Exception | None,
|
||||||
|
@ -1077,7 +1142,7 @@ async def test_create_backup_failure_raises_issue(
|
||||||
await ws_client.send_json_auto_id(
|
await ws_client.send_json_auto_id(
|
||||||
{
|
{
|
||||||
"type": "backup/config/update",
|
"type": "backup/config/update",
|
||||||
"create_backup": {"agent_ids": ["test.remote"]},
|
"create_backup": {"agent_ids": automatic_agents},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = await ws_client.receive_json()
|
result = await ws_client.receive_json()
|
||||||
|
|
Loading…
Reference in New Issue