Skip to content

Commit ffcfe16

Browse files
Create repair for configured not loaded agents
1 parent 2f5816c commit ffcfe16

File tree

5 files changed

+310
-1
lines changed

5 files changed

+310
-1
lines changed

homeassistant/components/backup/manager.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
LOGGER,
5252
)
5353
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
54+
from .repairs import (
55+
create_automatic_backup_agents_not_loaded_issue,
56+
delete_automatic_backup_agents_not_loaded_issue,
57+
)
5458
from .store import BackupStore
5559
from .util import (
5660
AsyncIteratorReader,
@@ -401,6 +405,19 @@ async def _async_reload_backup_agents(self, domain: str) -> None:
401405
if isinstance(agent, LocalBackupAgent)
402406
}
403407
)
408+
if missing_agent_ids := set(self.config.data.create_backup.agent_ids) - set(
409+
self.backup_agents
410+
):
411+
LOGGER.debug(
412+
"Agents %s are configured for automatic backup but are not loaded",
413+
missing_agent_ids,
414+
)
415+
for agent_id in missing_agent_ids:
416+
create_automatic_backup_agents_not_loaded_issue(self.hass, agent_id)
417+
418+
# Remove any issues for agents that are now loaded
419+
for agent_id in self.backup_agents:
420+
delete_automatic_backup_agents_not_loaded_issue(self.hass, agent_id)
404421

405422
async def _add_platform(
406423
self,

homeassistant/components/backup/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "Backup",
44
"after_dependencies": ["hassio"],
55
"codeowners": ["@home-assistant/core"],
6-
"dependencies": ["http", "websocket_api"],
6+
"dependencies": ["http", "repairs", "websocket_api"],
77
"documentation": "https://www.home-assistant.io/integrations/backup",
88
"integration_type": "system",
99
"iot_class": "calculated",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Provide repairs for the backup integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import cast
6+
7+
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
8+
from homeassistant.core import HomeAssistant, callback
9+
from homeassistant.data_entry_flow import FlowResult
10+
from homeassistant.helpers import issue_registry as ir
11+
12+
from .const import DATA_MANAGER, DOMAIN
13+
14+
AUTOMATIC_BACKUP_AGENTS_NOT_LOADED_ISSUE_ID = "automatic_backup_agents_not_loaded"
15+
16+
17+
@callback
18+
def create_automatic_backup_agents_not_loaded_issue(
19+
hass: HomeAssistant, agent_id: str
20+
) -> None:
21+
"""Create automatic backup agents not loaded issue."""
22+
ir.async_create_issue(
23+
hass,
24+
DOMAIN,
25+
f"{AUTOMATIC_BACKUP_AGENTS_NOT_LOADED_ISSUE_ID}_{agent_id}",
26+
data={"agent_id": agent_id},
27+
is_fixable=True,
28+
learn_more_url="homeassistant://config/backup",
29+
severity=ir.IssueSeverity.WARNING,
30+
translation_key="automatic_backup_agents_not_loaded",
31+
translation_placeholders={"agent_id": agent_id},
32+
)
33+
34+
35+
@callback
36+
def delete_automatic_backup_agents_not_loaded_issue(
37+
hass: HomeAssistant, agent_id: str
38+
) -> None:
39+
"""Delete automatic backup agents not loaded issue."""
40+
ir.async_delete_issue(
41+
hass, DOMAIN, f"{AUTOMATIC_BACKUP_AGENTS_NOT_LOADED_ISSUE_ID}_{agent_id}"
42+
)
43+
44+
45+
class AutomaticBackupAgentsNotLoaded(RepairsFlow):
46+
"""Handler for an issue fixing flow."""
47+
48+
def __init__(self, agent_id: str) -> None:
49+
"""Initialize."""
50+
self._agent_id = agent_id
51+
52+
async def async_step_init(
53+
self, user_input: dict[str, str] | None = None
54+
) -> FlowResult:
55+
"""Handle the first step of a fix flow."""
56+
return await self.async_step_confirm()
57+
58+
async def async_step_confirm(
59+
self, user_input: dict[str, str] | None = None
60+
) -> FlowResult:
61+
"""Handle the confirm step of a fix flow."""
62+
if user_input is not None:
63+
manager = self.hass.data[DATA_MANAGER]
64+
configured_agent_ids = set(manager.config.data.create_backup.agent_ids)
65+
configured_agent_ids.discard(self._agent_id)
66+
await manager.config.update(
67+
create_backup={"agent_ids": list(configured_agent_ids)}
68+
)
69+
70+
return self.async_create_entry(title="", data={})
71+
72+
return self.async_show_form(
73+
step_id="confirm", description_placeholders={"agent_id": self._agent_id}
74+
)
75+
76+
77+
async def async_create_fix_flow(
78+
hass: HomeAssistant,
79+
issue_id: str,
80+
data: dict[str, str | int | float | None] | None,
81+
) -> RepairsFlow:
82+
"""Create flow."""
83+
if AUTOMATIC_BACKUP_AGENTS_NOT_LOADED_ISSUE_ID in issue_id:
84+
assert data
85+
agent_id = cast(str, data["agent_id"])
86+
return AutomaticBackupAgentsNotLoaded(agent_id=agent_id)
87+
return ConfirmRepairFlow()

homeassistant/components/backup/strings.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
{
22
"issues": {
3+
"automatic_backup_agents_not_loaded": {
4+
"title": "The backup location {agent_id} is not loaded",
5+
"fix_flow": {
6+
"step": {
7+
"confirm": {
8+
"title": "The backup location {agent_id} is not loaded",
9+
"description": "The backup location `{agent_id}` is not loaded but is still configured for automatic backups.\n\nTo remove this backup location from configured automatic backups, press the submit button. Closing this dialog will keep the backup location configured but it will not be working until the backup location is loaded successfully again."
10+
}
11+
}
12+
}
13+
},
314
"automatic_backup_failed_create": {
415
"title": "Automatic backup could not be created",
516
"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."
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Test the backup repair flows."""
2+
3+
from http import HTTPStatus
4+
from typing import Any
5+
from unittest.mock import AsyncMock, Mock
6+
7+
from homeassistant.components.backup import DOMAIN, store
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers import issue_registry as ir
10+
from homeassistant.setup import async_setup_component
11+
12+
from .common import BackupAgentTest, setup_backup_platform
13+
14+
from tests.typing import ClientSessionGenerator, WebSocketGenerator
15+
16+
17+
async def test_agents_not_loaded_repair_flow(
18+
hass: HomeAssistant,
19+
hass_client: ClientSessionGenerator,
20+
hass_ws_client: WebSocketGenerator,
21+
issue_registry: ir.IssueRegistry,
22+
hass_storage: dict[str, Any],
23+
) -> None:
24+
"""Test desired flow of the fix flow for legacy subscription."""
25+
issue_id = "automatic_backup_agents_not_loaded_test.agent"
26+
ws_client = await hass_ws_client(hass)
27+
hass_storage.update(
28+
{
29+
"backup": {
30+
"data": {
31+
"backups": [],
32+
"config": {
33+
"agents": {},
34+
"create_backup": {
35+
"agent_ids": ["test.agent"],
36+
"include_addons": None,
37+
"include_all_addons": False,
38+
"include_database": False,
39+
"include_folders": None,
40+
"name": None,
41+
"password": None,
42+
},
43+
"retention": {"copies": None, "days": None},
44+
"last_attempted_automatic_backup": None,
45+
"last_completed_automatic_backup": None,
46+
"schedule": {
47+
"days": ["mon"],
48+
"recurrence": "custom_days",
49+
"state": "never",
50+
"time": None,
51+
},
52+
},
53+
},
54+
"key": DOMAIN,
55+
"version": store.STORAGE_VERSION,
56+
"minor_version": store.STORAGE_VERSION_MINOR,
57+
},
58+
}
59+
)
60+
assert await async_setup_component(hass, DOMAIN, {})
61+
62+
get_agents_mock = AsyncMock(return_value=[BackupAgentTest("agent", backups=[])])
63+
register_listener_mock = Mock()
64+
65+
await setup_backup_platform(
66+
hass,
67+
domain="test",
68+
platform=Mock(
69+
async_get_backup_agents=get_agents_mock,
70+
async_register_backup_agents_listener=register_listener_mock,
71+
),
72+
)
73+
await hass.async_block_till_done()
74+
75+
reload_backup_agents = register_listener_mock.call_args[1]["listener"]
76+
77+
client = await hass_client()
78+
79+
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
80+
resp = await ws_client.receive_json()
81+
assert resp["result"]["agents"] == [
82+
{"agent_id": "backup.local", "name": "local"},
83+
{"agent_id": "test.agent", "name": "agent"},
84+
]
85+
86+
repair_issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
87+
assert not repair_issue
88+
89+
# Reload the agents with no agents returned.
90+
91+
get_agents_mock.return_value = []
92+
reload_backup_agents()
93+
await hass.async_block_till_done()
94+
95+
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
96+
resp = await ws_client.receive_json()
97+
assert resp["result"]["agents"] == [
98+
{"agent_id": "backup.local", "name": "local"},
99+
]
100+
101+
repair_issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
102+
assert repair_issue
103+
104+
# Start the repair flow.
105+
106+
resp = await client.post(
107+
"/api/repairs/issues/fix",
108+
json={"handler": DOMAIN, "issue_id": issue_id},
109+
)
110+
111+
assert resp.status == HTTPStatus.OK
112+
data = await resp.json()
113+
114+
flow_id = data["flow_id"]
115+
assert data == {
116+
"type": "form",
117+
"flow_id": flow_id,
118+
"handler": DOMAIN,
119+
"step_id": "confirm",
120+
"data_schema": [],
121+
"errors": None,
122+
"description_placeholders": {"agent_id": "test.agent"},
123+
"last_step": None,
124+
"preview": None,
125+
}
126+
127+
# Aborting the flow should not remove the issue and not update the config.
128+
129+
resp = await client.delete(f"/api/repairs/issues/fix/{flow_id}")
130+
131+
assert resp.status == HTTPStatus.OK
132+
data = await resp.json()
133+
134+
assert data == {"message": "Flow aborted"}
135+
136+
resp = await client.get(f"/api/repairs/issues/fix/{flow_id}")
137+
138+
assert resp.status == HTTPStatus.NOT_FOUND
139+
data = await resp.json()
140+
141+
repair_issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
142+
assert repair_issue
143+
144+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
145+
result = await ws_client.receive_json()
146+
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"]
147+
148+
# Start the repair flow again.
149+
150+
resp = await client.post(
151+
"/api/repairs/issues/fix",
152+
json={"handler": DOMAIN, "issue_id": issue_id},
153+
)
154+
155+
assert resp.status == HTTPStatus.OK
156+
data = await resp.json()
157+
158+
flow_id = data["flow_id"]
159+
assert data == {
160+
"type": "form",
161+
"flow_id": flow_id,
162+
"handler": DOMAIN,
163+
"step_id": "confirm",
164+
"data_schema": [],
165+
"errors": None,
166+
"description_placeholders": {"agent_id": "test.agent"},
167+
"last_step": None,
168+
"preview": None,
169+
}
170+
171+
# Finish the flow.
172+
173+
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
174+
175+
assert resp.status == HTTPStatus.OK
176+
data = await resp.json()
177+
178+
flow_id = data["flow_id"]
179+
assert data == {
180+
"type": "create_entry",
181+
"flow_id": flow_id,
182+
"handler": DOMAIN,
183+
"description": None,
184+
"description_placeholders": None,
185+
"title": "",
186+
}
187+
188+
assert not issue_registry.async_get_issue(
189+
domain=DOMAIN, issue_id="automatic_backup_agents_not_loaded"
190+
)
191+
192+
await ws_client.send_json_auto_id({"type": "backup/config/info"})
193+
result = await ws_client.receive_json()
194+
assert result["result"]["config"]["create_backup"]["agent_ids"] == []

0 commit comments

Comments
 (0)