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
pull/139272/head
Martin Hjelmare 2025-02-25 16:27:56 +01:00 committed by GitHub
parent f607b95c00
commit 27f7085b61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 262 additions and 3 deletions

View File

@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
from .const import LOGGER
from .const import DOMAIN, LOGGER
from .models import BackupManagerError, Folder
if TYPE_CHECKING:
from .manager import BackupManager, ManagerBackup
AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable"
CRON_PATTERN_DAILY = "{m} {h} * * *"
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
@ -151,6 +154,7 @@ class BackupConfig:
retention=RetentionConfig(),
schedule=BackupSchedule(),
)
self._hass = hass
self._manager = manager
def load(self, stored_config: StoredBackupConfig) -> None:
@ -182,6 +186,8 @@ class BackupConfig:
self.data.automatic_backups_configured = automatic_backups_configured
if create_backup is not UNDEFINED:
self.data.create_backup = replace(self.data.create_backup, **create_backup)
if "agent_ids" in create_backup:
check_unavailable_agents(self._hass, self._manager)
if retention is not UNDEFINED:
new_retention = RetentionConfig(**retention)
if new_retention != self.data.retention:
@ -562,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
@callback
def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None:
"""Check for unavailable agents."""
if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set(
manager.backup_agents
):
LOGGER.debug(
"Agents %s are configured for automatic backup but are unavailable",
missing_agent_ids,
)
# Remove issues for unavailable agents that are not unavailable anymore.
issue_registry = ir.async_get(hass)
existing_missing_agent_issue_ids = {
issue_id
for domain, issue_id in issue_registry.issues
if domain == DOMAIN
and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID)
}
current_missing_agent_issue_ids = {
f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id
for agent_id in missing_agent_ids
}
for issue_id in existing_missing_agent_issue_ids - set(
current_missing_agent_issue_ids
):
ir.async_delete_issue(hass, DOMAIN, issue_id)
for issue_id, agent_id in current_missing_agent_issue_ids.items():
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
learn_more_url="homeassistant://config/backup",
severity=ir.IssueSeverity.WARNING,
translation_key="automatic_backup_agents_unavailable",
translation_placeholders={
"agent_id": agent_id,
"backup_settings": "/config/backup/settings",
},
)

View File

@ -32,6 +32,7 @@ from homeassistant.helpers import (
instance_id,
integration_platform,
issue_registry as ir,
start,
)
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.json import json_bytes
@ -47,6 +48,7 @@ from .agent import (
from .config import (
BackupConfig,
CreateBackupParametersDict,
check_unavailable_agents,
delete_backups_exceeding_configured_count,
)
from .const import (
@ -417,6 +419,13 @@ class BackupManager:
}
)
@callback
def check_unavailable_agents_after_start(hass: HomeAssistant) -> None:
"""Check unavailable agents after start."""
check_unavailable_agents(hass, self)
start.async_at_started(self.hass, check_unavailable_agents_after_start)
async def _add_platform(
self,
hass: HomeAssistant,

View File

@ -1,5 +1,9 @@
{
"issues": {
"automatic_backup_agents_unavailable": {
"title": "The backup location {agent_id} is unavailable",
"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."
},
"automatic_backup_failed_create": {
"title": "Automatic backup could not be created",
"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."

View File

@ -982,7 +982,15 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
None,
None,
True,
{},
{
(DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): {
"translation_key": "automatic_backup_agents_unavailable",
"translation_placeholders": {
"agent_id": "test.unknown",
"backup_settings": "/config/backup/settings",
},
},
},
),
(
["test.remote", "test.unknown"],
@ -994,7 +1002,14 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
(DOMAIN, "automatic_backup_failed"): {
"translation_key": "automatic_backup_failed_upload_agents",
"translation_placeholders": {"failed_agents": "test.unknown"},
}
},
(DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): {
"translation_key": "automatic_backup_agents_unavailable",
"translation_placeholders": {
"agent_id": "test.unknown",
"backup_settings": "/config/backup/settings",
},
},
},
),
# Error raised in async_initiate_backup

View File

@ -27,6 +27,7 @@ from homeassistant.components.backup.manager import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.backup import async_initialize_backup
from homeassistant.setup import async_setup_component
@ -34,7 +35,9 @@ from .common import (
LOCAL_AGENT_ID,
TEST_BACKUP_ABC123,
TEST_BACKUP_DEF456,
mock_backup_agent,
setup_backup_integration,
setup_backup_platform,
)
from tests.common import async_fire_time_changed, async_mock_service
@ -3244,6 +3247,185 @@ async def test_config_retention_days_logic(
await hass.async_block_till_done()
async def test_configured_agents_unavailable_repair(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
issue_registry: ir.IssueRegistry,
hass_storage: dict[str, Any],
) -> None:
"""Test creating and deleting repair issue for configured unavailable agents."""
issue_id = "automatic_backup_agents_unavailable_test.agent"
ws_client = await hass_ws_client(hass)
hass_storage.update(
{
"backup": {
"data": {
"backups": [],
"config": {
"agents": {},
"automatic_backups_configured": True,
"create_backup": {
"agent_ids": ["test.agent"],
"include_addons": None,
"include_all_addons": False,
"include_database": False,
"include_folders": None,
"name": None,
"password": None,
},
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"schedule": {
"days": ["mon"],
"recurrence": "custom_days",
"state": "never",
"time": None,
},
},
},
"key": DOMAIN,
"version": store.STORAGE_VERSION,
"minor_version": store.STORAGE_VERSION_MINOR,
},
}
)
await setup_backup_integration(hass)
get_agents_mock = AsyncMock(return_value=[mock_backup_agent("agent")])
register_listener_mock = Mock()
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=get_agents_mock,
async_register_backup_agents_listener=register_listener_mock,
),
)
await hass.async_block_till_done()
reload_backup_agents = register_listener_mock.call_args[1]["listener"]
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
resp = await ws_client.receive_json()
assert resp["result"]["agents"] == [
{"agent_id": "backup.local", "name": "local"},
{"agent_id": "test.agent", "name": "agent"},
]
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
# Reload the agents with no agents returned.
get_agents_mock.return_value = []
reload_backup_agents()
await hass.async_block_till_done()
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
resp = await ws_client.receive_json()
assert resp["result"]["agents"] == [
{"agent_id": "backup.local", "name": "local"},
]
assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
await ws_client.send_json_auto_id({"type": "backup/config/info"})
result = await ws_client.receive_json()
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"]
# Update the automatic backup configuration removing the unavailable agent.
await ws_client.send_json_auto_id(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["backup.local"]},
}
)
result = await ws_client.receive_json()
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
await ws_client.send_json_auto_id({"type": "backup/config/info"})
result = await ws_client.receive_json()
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"]
# Reload the agents with one agent returned
# but not configured for automatic backups.
get_agents_mock.return_value = [mock_backup_agent("agent")]
reload_backup_agents()
await hass.async_block_till_done()
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
resp = await ws_client.receive_json()
assert resp["result"]["agents"] == [
{"agent_id": "backup.local", "name": "local"},
{"agent_id": "test.agent", "name": "agent"},
]
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
await ws_client.send_json_auto_id({"type": "backup/config/info"})
result = await ws_client.receive_json()
assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"]
# Update the automatic backup configuration and configure the test agent.
await ws_client.send_json_auto_id(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["backup.local", "test.agent"]},
}
)
result = await ws_client.receive_json()
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
await ws_client.send_json_auto_id({"type": "backup/config/info"})
result = await ws_client.receive_json()
assert result["result"]["config"]["create_backup"]["agent_ids"] == [
"backup.local",
"test.agent",
]
# Reload the agents with no agents returned again.
get_agents_mock.return_value = []
reload_backup_agents()
await hass.async_block_till_done()
await ws_client.send_json_auto_id({"type": "backup/agents/info"})
resp = await ws_client.receive_json()
assert resp["result"]["agents"] == [
{"agent_id": "backup.local", "name": "local"},
]
assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
await ws_client.send_json_auto_id({"type": "backup/config/info"})
result = await ws_client.receive_json()
assert result["result"]["config"]["create_backup"]["agent_ids"] == [
"backup.local",
"test.agent",
]
# Update the automatic backup configuration removing all agents.
await ws_client.send_json_auto_id(
{
"type": "backup/config/update",
"create_backup": {"agent_ids": []},
}
)
result = await ws_client.receive_json()
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id)
await ws_client.send_json_auto_id({"type": "backup/config/info"})
result = await ws_client.receive_json()
assert result["result"]["config"]["create_backup"]["agent_ids"] == []
async def test_subscribe_event(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,