diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index f4fa2e8bac6..75576105e92 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -87,12 +88,26 @@ class BackupConfigData: else: time = None days = [Day(day) for day in data["schedule"]["days"]] + agents = {} + for agent_id, agent_data in data["agents"].items(): + protected = agent_data["protected"] + stored_retention = agent_data["retention"] + agent_retention: AgentRetentionConfig | None + if stored_retention: + agent_retention = AgentRetentionConfig( + copies=stored_retention["copies"], + days=stored_retention["days"], + ) + else: + agent_retention = None + agent_config = AgentConfig( + protected=protected, + retention=agent_retention, + ) + agents[agent_id] = agent_config return cls( - agents={ - agent_id: AgentConfig(protected=agent_data["protected"]) - for agent_id, agent_data in data["agents"].items() - }, + agents=agents, automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], @@ -176,12 +191,36 @@ class BackupConfig: """Update config.""" if agents is not UNDEFINED: for agent_id, agent_config in agents.items(): - if agent_id not in self.data.agents: - self.data.agents[agent_id] = AgentConfig(**agent_config) + agent_retention = agent_config.get("retention") + if agent_retention is None: + new_agent_retention = None else: - self.data.agents[agent_id] = replace( - self.data.agents[agent_id], **agent_config + new_agent_retention = AgentRetentionConfig( + copies=agent_retention.get("copies"), + days=agent_retention.get("days"), ) + if agent_id not in self.data.agents: + old_agent_retention = None + self.data.agents[agent_id] = AgentConfig( + protected=agent_config.get("protected", False), + retention=new_agent_retention, + ) + else: + new_agent_config = self.data.agents[agent_id] + old_agent_retention = new_agent_config.retention + if "protected" in agent_config: + new_agent_config = replace( + new_agent_config, protected=agent_config["protected"] + ) + if "retention" in agent_config: + new_agent_config = replace( + new_agent_config, retention=new_agent_retention + ) + self.data.agents[agent_id] = new_agent_config + if new_agent_retention != old_agent_retention: + # There's a single retention application method + # for both global and agent retention settings. + self.data.retention.apply(self._manager) if automatic_backups_configured is not UNDEFINED: self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: @@ -207,11 +246,24 @@ class AgentConfig: """Represent the config for an agent.""" protected: bool + """Agent protected configuration. + + If True, the agent backups are password protected. + """ + retention: AgentRetentionConfig | None = None + """Agent retention configuration. + + If None, the global retention configuration is used. + If not None, the global retention configuration is ignored for this agent. + If an agent retention configuration is set and both copies and days are None, + backups will be kept forever for that agent. + """ def to_dict(self) -> StoredAgentConfig: """Convert agent config to a dict.""" return { "protected": self.protected, + "retention": self.retention.to_dict() if self.retention else None, } @@ -219,24 +271,46 @@ class StoredAgentConfig(TypedDict): """Represent the stored config for an agent.""" protected: bool + retention: StoredRetentionConfig | None class AgentParametersDict(TypedDict, total=False): """Represent the parameters for an agent.""" protected: bool + retention: RetentionParametersDict | None @dataclass(kw_only=True) -class RetentionConfig: - """Represent the backup retention configuration.""" +class BaseRetentionConfig: + """Represent the base backup retention configuration.""" copies: int | None = None days: int | None = None + def to_dict(self) -> StoredRetentionConfig: + """Convert backup retention configuration to a dict.""" + return StoredRetentionConfig( + copies=self.copies, + days=self.days, + ) + + +@dataclass(kw_only=True) +class RetentionConfig(BaseRetentionConfig): + """Represent the backup retention configuration.""" + def apply(self, manager: BackupManager) -> None: """Apply backup retention configuration.""" - if self.days is not None: + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() + } + + if self.days is not None or any( + agent_retention and agent_retention.days is not None + for agent_retention in agents_retention.values() + ): LOGGER.debug( "Scheduling next automatic delete of backups older than %s in 1 day", self.days, @@ -246,13 +320,6 @@ class RetentionConfig: LOGGER.debug("Unscheduling next automatic delete") self._unschedule_next(manager) - def to_dict(self) -> StoredRetentionConfig: - """Convert backup retention configuration to a dict.""" - return StoredRetentionConfig( - copies=self.copies, - days=self.days, - ) - @callback def _schedule_next( self, @@ -271,16 +338,81 @@ class RetentionConfig: """Return backups older than days to delete.""" # we need to check here since we await before # this filter is applied - if self.days is None: - return {} - now = dt_util.utcnow() - return { - backup_id: backup - for backup_id, backup in backups.items() - if dt_util.parse_datetime(backup.date, raise_on_error=True) - + timedelta(days=self.days) - < now + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() } + has_agents_retention = any( + agent_retention for agent_retention in agents_retention.values() + ) + has_agents_retention_days = any( + agent_retention and agent_retention.days is not None + for agent_retention in agents_retention.values() + ) + if (global_days := self.days) is None and not has_agents_retention_days: + # No global retention days and no agent retention days + return {} + + now = dt_util.utcnow() + if global_days is not None and not has_agents_retention: + # Return early to avoid the longer filtering below. + return { + backup_id: backup + for backup_id, backup in backups.items() + if dt_util.parse_datetime(backup.date, raise_on_error=True) + + timedelta(days=global_days) + < now + } + + # If there are any agent retention settings, we need to check + # the retention settings, for every backup and agent combination. + + backups_to_delete = {} + + for backup_id, backup in backups.items(): + backup_date = dt_util.parse_datetime( + backup.date, raise_on_error=True + ) + delete_from_agents = set(backup.agents) + for agent_id in backup.agents: + agent_retention = agents_retention.get(agent_id) + if agent_retention is None: + # This agent does not have a retention setting, + # so the global retention setting should be used. + if global_days is None: + # This agent does not have a retention setting + # and the global retention days setting is None, + # so this backup should not be deleted. + delete_from_agents.discard(agent_id) + continue + days = global_days + elif (agent_days := agent_retention.days) is None: + # This agent has a retention setting + # where days is set to None, + # so the backup should not be deleted. + delete_from_agents.discard(agent_id) + continue + else: + # This agent has a retention setting + # where days is set to a number, + # so that setting should be used. + days = agent_days + if backup_date + timedelta(days=days) >= now: + # This backup is not older than the retention days, + # so this agent should not be deleted. + delete_from_agents.discard(agent_id) + + filtered_backup = replace( + backup, + agents={ + agent_id: agent_backup_status + for agent_id, agent_backup_status in backup.agents.items() + if agent_id in delete_from_agents + }, + ) + backups_to_delete[backup_id] = filtered_backup + + return backups_to_delete await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter @@ -312,6 +444,10 @@ class RetentionParametersDict(TypedDict, total=False): days: int | None +class AgentRetentionConfig(BaseRetentionConfig): + """Represent an agent retention configuration.""" + + class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" @@ -554,16 +690,87 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() + } + has_agents_retention = any( + agent_retention for agent_retention in agents_retention.values() + ) + has_agents_retention_copies = any( + agent_retention and agent_retention.copies is not None + for agent_retention in agents_retention.values() + ) # we need to check here since we await before # this filter is applied - if manager.config.data.retention.copies is None: + if ( + global_copies := manager.config.data.retention.copies + ) is None and not has_agents_retention_copies: + # No global retention copies and no agent retention copies return {} - return dict( - sorted( - backups.items(), - key=lambda backup_item: backup_item[1].date, - )[: max(len(backups) - manager.config.data.retention.copies, 0)] + if global_copies is not None and not has_agents_retention: + # Return early to avoid the longer filtering below. + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - global_copies, 0)] + ) + + backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict) + for backup_id, backup in backups.items(): + for agent_id in backup.agents: + backups_by_agent[agent_id][backup_id] = backup + + backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict( + dict ) + for agent_id, agent_backups in backups_by_agent.items(): + agent_retention = agents_retention.get(agent_id) + if agent_retention is None: + # This agent does not have a retention setting, + # so the global retention setting should be used. + if global_copies is None: + # This agent does not have a retention setting + # and the global retention copies setting is None, + # so backups should not be deleted. + continue + # The global retention setting will be used. + copies = global_copies + elif (agent_copies := agent_retention.copies) is None: + # This agent has a retention setting + # where copies is set to None, + # so backups should not be deleted. + continue + else: + # This agent retention setting will be used. + copies = agent_copies + + backups_to_delete_by_agent[agent_id] = dict( + sorted( + agent_backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(agent_backups) - copies, 0)] + ) + + backup_ids_to_delete: dict[str, set[str]] = defaultdict(set) + for agent_id, to_delete in backups_to_delete_by_agent.items(): + for backup_id in to_delete: + backup_ids_to_delete[backup_id].add(agent_id) + backups_to_delete: dict[str, ManagerBackup] = {} + for backup_id, agent_ids in backup_ids_to_delete.items(): + backup = backups[backup_id] + # filter the backup to only include the agents that should be deleted + filtered_backup = replace( + backup, + agents={ + agent_id: agent_backup_status + for agent_id, agent_backup_status in backup.agents.items() + if agent_id in agent_ids + }, + ) + backups_to_delete[backup_id] = filtered_backup + return backups_to_delete await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 883447853e6..6472f8ae151 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 5 +STORAGE_VERSION_MINOR = 6 class StoredBackupData(TypedDict): @@ -72,6 +72,10 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["automatic_backups_configured"] = ( data["config"]["create_backup"]["password"] is not None ) + if old_minor_version < 6: + # Version 1.6 adds agent retention settings + for agent in data["config"]["agents"]: + data["config"]["agents"][agent]["retention"] = None # Note: We allow reading data with major version 2. # Reject if major version is higher than 2. diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 4c370a4224d..080b5bb18a8 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -346,7 +346,28 @@ async def handle_config_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/config/update", - vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), + vol.Optional("agents"): vol.Schema( + { + str: { + vol.Optional("protected"): bool, + vol.Optional("retention"): vol.Any( + vol.Schema( + { + # Note: We can't use cv.positive_int because it allows 0 even + # though 0 is not positive. + vol.Optional("copies"): vol.Any( + vol.All(int, vol.Range(min=1)), None + ), + vol.Optional("days"): vol.Any( + vol.All(int, vol.Range(min=1)), None + ), + }, + ), + None, + ), + } + } + ), vol.Optional("automatic_backups_configured"): bool, vol.Optional("create_backup"): vol.Schema( { diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 41778322825..6f1bce8d5e4 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -40,7 +40,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -86,7 +86,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -177,7 +177,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -196,6 +196,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -225,7 +226,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -244,6 +245,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -274,7 +276,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -293,6 +295,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -322,7 +325,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -341,6 +344,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -371,7 +375,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -390,6 +394,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': True, @@ -419,7 +424,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -438,6 +443,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': True, @@ -468,7 +474,112 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data5] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 6, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data5].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 6, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 0bef632f0b4..7528785ab0d 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -300,6 +300,61 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[with_hassio-storage_data10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_config_load_config_info[with_hassio-storage_data1] dict({ 'id': 1, @@ -556,9 +611,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -714,6 +771,61 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[without_hassio-storage_data10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_config_load_config_info[without_hassio-storage_data1] dict({ 'id': 1, @@ -966,9 +1078,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1198,7 +1312,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1315,7 +1429,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1432,7 +1546,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1482,9 +1596,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1527,9 +1643,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1559,7 +1677,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1609,9 +1727,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1653,9 +1773,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': False, + 'retention': None, }), 'test-agent2': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1690,6 +1812,104 @@ }) # --- # name: test_config_update[commands13].3 + dict({ + 'id': 7, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': True, + 'retention': None, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].4 + dict({ + 'id': 9, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + 'retention': None, + }), + 'test-agent2': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].5 dict({ 'data': dict({ 'backups': list([ @@ -1698,9 +1918,14 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': False, + 'retention': None, }), 'test-agent2': dict({ 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), }), }), 'automatic_backups_configured': False, @@ -1730,7 +1955,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1845,7 +2070,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1960,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2077,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2196,7 +2421,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2313,7 +2538,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2434,7 +2659,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2559,7 +2784,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2676,7 +2901,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2793,7 +3018,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2910,7 +3135,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -3027,7 +3252,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -3259,6 +3484,158 @@ 'type': 'result', }) # --- +# name: test_config_update_errors[command12] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command12].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command13] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command13].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_config_update_errors[command1] dict({ 'id': 1, diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index 0d29bb2006a..b078dcc2be7 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -98,7 +98,7 @@ def mock_delay_save() -> Generator[None]: } ], "config": { - "agents": {"test.remote": {"protected": True}}, + "agents": {"test.remote": {"protected": True, "retention": None}}, "automatic_backups_configured": False, "create_backup": { "agent_ids": [], @@ -200,6 +200,49 @@ def mock_delay_save() -> Generator[None]: "minor_version": 4, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": { + "test.remote": { + "protected": True, + "retention": {"copies": None, "days": None}, + } + }, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 6, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index d89e68f4ed8..e6a59142ca2 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,6 +1,7 @@ """Tests for the Backup integration.""" from collections.abc import Generator +from dataclasses import replace from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch @@ -9,6 +10,7 @@ import pytest from syrupy import SnapshotAssertion from homeassistant.components.backup import ( + AddonInfo, AgentBackup, BackupAgentError, BackupNotFound, @@ -81,6 +83,21 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { } DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +TEST_MANAGER_BACKUP = ManagerBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={"test.test-agent": AgentBackupStatus(protected=True, size=0)}, + backup_id="backup-1", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, +) + @pytest.fixture def sync_access_token_proxy( @@ -1160,8 +1177,8 @@ async def test_agents_info( "backups": [], "config": { "agents": { - "test-agent1": {"protected": True}, - "test-agent2": {"protected": False}, + "test-agent1": {"protected": True, "retention": None}, + "test-agent2": {"protected": False, "retention": None}, }, "automatic_backups_configured": False, "create_backup": { @@ -1253,6 +1270,47 @@ async def test_agents_info( "minor_version": store.STORAGE_VERSION_MINOR, }, }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": { + "protected": True, + "retention": {"copies": 3, "days": None}, + }, + "test-agent2": { + "protected": False, + "retention": {"copies": None, "days": 7}, + }, + }, + "automatic_backups_configured": False, + "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", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, ], ) @pytest.mark.parametrize( @@ -1271,7 +1329,7 @@ async def test_config_load_config_info( snapshot: SnapshotAssertion, hass_storage: dict[str, Any], with_hassio: bool, - storage_data: dict[str, Any] | None, + storage_data: dict[str, Any], ) -> None: """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) @@ -1412,6 +1470,20 @@ async def test_config_load_config_info( "test-agent2": {"protected": True}, }, }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"retention": {"copies": 3}}, + "test-agent2": {"retention": None}, + }, + }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"retention": None}, + "test-agent2": {"retention": {"days": 7}}, + }, + }, ], [ { @@ -1433,7 +1505,7 @@ async def test_config_update( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - commands: dict[str, Any], + commands: list[dict[str, Any]], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" @@ -1522,6 +1594,14 @@ async def test_config_update( "type": "backup/config/update", "retention": {"days": 0}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"retention": {"copies": 0}}}, + }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"retention": {"days": 0}}}, + }, ], ) async def test_config_update_errors( @@ -2489,6 +2569,253 @@ async def test_config_schedule_logic( 1, {}, ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": { + "copies": 1, + "days": None, + }, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": { + "copies": 1, + "days": None, + }, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": { + "copies": None, + "days": None, + }, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent2": [call("backup-1")], + }, + ), ], ) @patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) @@ -3221,6 +3548,223 @@ async def test_config_retention_copies_logic_manual_backup( 1, {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": {"days": 3}, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": {"days": 3}, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1")], + }, + ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": {"copies": None, "days": None}, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1"), call("backup-2")], + }, + ), ], ) async def test_config_retention_days_logic( @@ -3278,7 +3822,7 @@ async def test_config_retention_days_logic( freezer.move_to(start_time) mock_agents = await setup_backup_integration( - hass, remote_agents=["test.test-agent"] + hass, remote_agents=["test.test-agent", "test.test-agent2"] ) await hass.async_block_till_done()