Allow creating backup if at least one agent is available (#137409)

pull/137448/head
Erik Montnemery 2025-02-05 10:14:39 +01:00 committed by Franck Nijhof
parent c506c9080a
commit 30c099ef4e
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
2 changed files with 114 additions and 22 deletions

View File

@ -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()

View File

@ -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()