Skip to content

Commit 27f7085

Browse files
Create repair for configured unavailable backup agents (#137382)
* Create repair for configured not loaded agents * Rework to repair issue * Extract logic to config function * Update test * Handle empty agend ids config update * Address review comment * Update tests * Address comment
1 parent f607b95 commit 27f7085

File tree

5 files changed

+262
-3
lines changed

5 files changed

+262
-3
lines changed

homeassistant/components/backup/config.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@
1212
from cronsim import CronSim
1313

1414
from homeassistant.core import HomeAssistant, callback
15+
from homeassistant.helpers import issue_registry as ir
1516
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
1617
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
1718
from homeassistant.util import dt as dt_util
1819

19-
from .const import LOGGER
20+
from .const import DOMAIN, LOGGER
2021
from .models import BackupManagerError, Folder
2122

2223
if TYPE_CHECKING:
2324
from .manager import BackupManager, ManagerBackup
2425

26+
AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable"
27+
2528
CRON_PATTERN_DAILY = "{m} {h} * * *"
2629
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
2730

@@ -151,6 +154,7 @@ def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
151154
retention=RetentionConfig(),
152155
schedule=BackupSchedule(),
153156
)
157+
self._hass = hass
154158
self._manager = manager
155159

156160
def load(self, stored_config: StoredBackupConfig) -> None:
@@ -182,6 +186,8 @@ def update(
182186
self.data.automatic_backups_configured = automatic_backups_configured
183187
if create_backup is not UNDEFINED:
184188
self.data.create_backup = replace(self.data.create_backup, **create_backup)
189+
if "agent_ids" in create_backup:
190+
check_unavailable_agents(self._hass, self._manager)
185191
if retention is not UNDEFINED:
186192
new_retention = RetentionConfig(**retention)
187193
if new_retention != self.data.retention:
@@ -562,3 +568,46 @@ def _delete_filter(
562568
await manager.async_delete_filtered_backups(
563569
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
564570
)
571+
572+
573+
@callback
574+
def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None:
575+
"""Check for unavailable agents."""
576+
if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set(
577+
manager.backup_agents
578+
):
579+
LOGGER.debug(
580+
"Agents %s are configured for automatic backup but are unavailable",
581+
missing_agent_ids,
582+
)
583+
584+
# Remove issues for unavailable agents that are not unavailable anymore.
585+
issue_registry = ir.async_get(hass)
586+
existing_missing_agent_issue_ids = {
587+
issue_id
588+
for domain, issue_id in issue_registry.issues
589+
if domain == DOMAIN
590+
and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID)
591+
}
592+
current_missing_agent_issue_ids = {
593+
f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id
594+
for agent_id in missing_agent_ids
595+
}
596+
for issue_id in existing_missing_agent_issue_ids - set(
597+
current_missing_agent_issue_ids
598+
):
599+
ir.async_delete_issue(hass, DOMAIN, issue_id)
600+
for issue_id, agent_id in current_missing_agent_issue_ids.items():
601+
ir.async_create_issue(
602+
hass,
603+
DOMAIN,
604+
issue_id,
605+
is_fixable=False,
606+
learn_more_url="homeassistant://config/backup",
607+
severity=ir.IssueSeverity.WARNING,
608+
translation_key="automatic_backup_agents_unavailable",
609+
translation_placeholders={
610+
"agent_id": agent_id,
611+
"backup_settings": "/config/backup/settings",
612+
},
613+
)

homeassistant/components/backup/manager.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
instance_id,
3333
integration_platform,
3434
issue_registry as ir,
35+
start,
3536
)
3637
from homeassistant.helpers.backup import DATA_BACKUP
3738
from homeassistant.helpers.json import json_bytes
@@ -47,6 +48,7 @@
4748
from .config import (
4849
BackupConfig,
4950
CreateBackupParametersDict,
51+
check_unavailable_agents,
5052
delete_backups_exceeding_configured_count,
5153
)
5254
from .const import (
@@ -417,6 +419,13 @@ async def _async_reload_backup_agents(self, domain: str) -> None:
417419
}
418420
)
419421

422+
@callback
423+
def check_unavailable_agents_after_start(hass: HomeAssistant) -> None:
424+
"""Check unavailable agents after start."""
425+
check_unavailable_agents(hass, self)
426+
427+
start.async_at_started(self.hass, check_unavailable_agents_after_start)
428+
420429
async def _add_platform(
421430
self,
422431
hass: HomeAssistant,

homeassistant/components/backup/strings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"issues": {
3+
"automatic_backup_agents_unavailable": {
4+
"title": "The backup location {agent_id} is unavailable",
5+
"description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable."
6+
},
37
"automatic_backup_failed_create": {
48
"title": "Automatic backup could not be created",
59
"description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."

tests/components/backup/test_manager.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,15 @@ async def delayed_boom() -> None:
982982
None,
983983
None,
984984
True,
985-
{},
985+
{
986+
(DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): {
987+
"translation_key": "automatic_backup_agents_unavailable",
988+
"translation_placeholders": {
989+
"agent_id": "test.unknown",
990+
"backup_settings": "/config/backup/settings",
991+
},
992+
},
993+
},
986994
),
987995
(
988996
["test.remote", "test.unknown"],
@@ -994,7 +1002,14 @@ async def delayed_boom() -> None:
9941002
(DOMAIN, "automatic_backup_failed"): {
9951003
"translation_key": "automatic_backup_failed_upload_agents",
9961004
"translation_placeholders": {"failed_agents": "test.unknown"},
997-
}
1005+
},
1006+
(DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): {
1007+
"translation_key": "automatic_backup_agents_unavailable",
1008+
"translation_placeholders": {
1009+
"agent_id": "test.unknown",
1010+
"backup_settings": "/config/backup/settings",
1011+
},
1012+
},
9981013
},
9991014
),
10001015
# Error raised in async_initiate_backup

tests/components/backup/test_websocket.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@
2727
)
2828
from homeassistant.core import HomeAssistant
2929
from homeassistant.exceptions import HomeAssistantError
30+
from homeassistant.helpers import issue_registry as ir
3031
from homeassistant.helpers.backup import async_initialize_backup
3132
from homeassistant.setup import async_setup_component
3233

3334
from .common import (
3435
LOCAL_AGENT_ID,
3536
TEST_BACKUP_ABC123,
3637
TEST_BACKUP_DEF456,
38+
mock_backup_agent,
3739
setup_backup_integration,
40+
setup_backup_platform,
3841
)
3942

4043
from tests.common import async_fire_time_changed, async_mock_service
@@ -3244,6 +3247,185 @@ async def test_config_retention_days_logic(
32443247
await hass.async_block_till_done()
32453248

32463249

3250+
async def test_configured_agents_unavailable_repair(
3251+
hass: HomeAssistant,
3252+
hass_ws_client: WebSocketGenerator,
3253+
issue_registry: ir.IssueRegistry,
3254+
hass_storage: dict[str, Any],
3255+
) -> None:
3256+
"""Test creating and deleting repair issue for configured unavailable agents."""
3257+
issue_id = "automatic_backup_agents_unavailable_test.agent"
3258+
ws_client = await hass_ws_client(hass)
3259+
hass_storage.update(
3260+
{
3261+
"backup": {
3262+
"data": {
3263+
"backups": [],
3264+
"config": {
3265+
"agents": {},
3266+
"automatic_backups_configured": True,
3267+
"create_backup": {
3268+
"agent_ids": ["test.agent"],
3269+
"include_addons": None,
3270+
"include_all_addons": False,
3271+
"include_database": False,
3272+
"include_folders": None,
3273+
"name": None,
3274+
"password": None,
3275+
},
3276+
"retention": {"copies": None, "days": None},
3277+
"last_attempted_automatic_backup": None,
3278+
"last_completed_automatic_backup": None,
3279+
"schedule": {
3280+
"days": ["mon"],
3281+
"recurrence": "custom_days",
3282+
"state": "never",
3283+
"time": None,
3284+
},
3285+
},
3286+
},
3287+
"key": DOMAIN,
3288+
"version": store.STORAGE_VERSION,
3289+
"minor_version": store.STORAGE_VERSION_MINOR,
3290+
},
3291+
}
3292+
)
3293+
3294+
await setup_backup_integration(hass)
3295+
get_agents_mock = AsyncMock(return_value=[mock_backup_agent("agent")])
3296+
register_listener_mock = Mock()
3297+
await setup_backup_platform(
3298+
hass,
3299+
domain="test",
3300+
platform=Mock(
3301+
async_get_backup_agents=get_agents_mock,
3302+
async_register_backup_agents_listener=register_listener_mock,
3303+
),
3304+
)
3305+
await hass.async_block_till_done()
3306+
3307+
reload_backup_agents = register_listener_mock.call_args[1]["listener"]
3308+
3309+
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
3310+
resp = await ws_client.receive_json()
3311+
assert resp["result"]["agents"] == [
3312+
{"agent_id": "backup.local", "name": "local"},
3313+
{"agent_id": "test.agent", "name": "agent"},
3314+
]
3315+
3316+
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3317+
3318+
# Reload the agents with no agents returned.
3319+
3320+
get_agents_mock.return_value = []
3321+
reload_backup_agents()
3322+
await hass.async_block_till_done()
3323+
3324+
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
3325+
resp = await ws_client.receive_json()
3326+
assert resp["result"]["agents"] == [
3327+
{"agent_id": "backup.local", "name": "local"},
3328+
]
3329+
3330+
assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3331+
3332+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
3333+
result = await ws_client.receive_json()
3334+
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"]
3335+
3336+
# Update the automatic backup configuration removing the unavailable agent.
3337+
3338+
await ws_client.send_json_auto_id(
3339+
{
3340+
"type": "backup/config/update",
3341+
"create_backup": {"agent_ids": ["backup.local"]},
3342+
}
3343+
)
3344+
result = await ws_client.receive_json()
3345+
3346+
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3347+
3348+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
3349+
result = await ws_client.receive_json()
3350+
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"]
3351+
3352+
# Reload the agents with one agent returned
3353+
# but not configured for automatic backups.
3354+
3355+
get_agents_mock.return_value = [mock_backup_agent("agent")]
3356+
reload_backup_agents()
3357+
await hass.async_block_till_done()
3358+
3359+
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
3360+
resp = await ws_client.receive_json()
3361+
assert resp["result"]["agents"] == [
3362+
{"agent_id": "backup.local", "name": "local"},
3363+
{"agent_id": "test.agent", "name": "agent"},
3364+
]
3365+
3366+
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3367+
3368+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
3369+
result = await ws_client.receive_json()
3370+
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"]
3371+
3372+
# Update the automatic backup configuration and configure the test agent.
3373+
3374+
await ws_client.send_json_auto_id(
3375+
{
3376+
"type": "backup/config/update",
3377+
"create_backup": {"agent_ids": ["backup.local", "test.agent"]},
3378+
}
3379+
)
3380+
result = await ws_client.receive_json()
3381+
3382+
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3383+
3384+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
3385+
result = await ws_client.receive_json()
3386+
assert result["result"]["config"]["create_backup"]["agent_ids"] == [
3387+
"backup.local",
3388+
"test.agent",
3389+
]
3390+
3391+
# Reload the agents with no agents returned again.
3392+
3393+
get_agents_mock.return_value = []
3394+
reload_backup_agents()
3395+
await hass.async_block_till_done()
3396+
3397+
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
3398+
resp = await ws_client.receive_json()
3399+
assert resp["result"]["agents"] == [
3400+
{"agent_id": "backup.local", "name": "local"},
3401+
]
3402+
3403+
assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3404+
3405+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
3406+
result = await ws_client.receive_json()
3407+
assert result["result"]["config"]["create_backup"]["agent_ids"] == [
3408+
"backup.local",
3409+
"test.agent",
3410+
]
3411+
3412+
# Update the automatic backup configuration removing all agents.
3413+
3414+
await ws_client.send_json_auto_id(
3415+
{
3416+
"type": "backup/config/update",
3417+
"create_backup": {"agent_ids": []},
3418+
}
3419+
)
3420+
result = await ws_client.receive_json()
3421+
3422+
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
3423+
3424+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
3425+
result = await ws_client.receive_json()
3426+
assert result["result"]["config"]["create_backup"]["agent_ids"] == []
3427+
3428+
32473429
async def test_subscribe_event(
32483430
hass: HomeAssistant,
32493431
hass_ws_client: WebSocketGenerator,

0 commit comments

Comments
 (0)