Add agent retention config

This commit is contained in:
Martin Hjelmare 2025-04-15 13:04:49 +02:00
parent 8355727eb1
commit 6ce90841dc
7 changed files with 1374 additions and 67 deletions

View File

@ -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", UNDEFINED)
if agent_retention is UNDEFINED or 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
)
agents_for_backup_to_delete = 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.
agents_for_backup_to_delete.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.
agents_for_backup_to_delete.discard(agent_id)
continue
else:
# This agent has a retention setting
# where days is set to a number,
# so the backup should be deleted.
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.
agents_for_backup_to_delete.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 agents_for_backup_to_delete
},
)
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 this backup should not be deleted.
continue
# This backup and agent combination should be deleted.
copies = global_copies
elif (agent_copies := agent_retention.copies) is None:
# This agent has a retention setting
# where copies is set to None,
# so the backup should not be deleted.
continue
else:
# This backup and agent combination should be deleted.
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

View File

@ -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.

View File

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

View File

@ -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,
})
# ---

View File

@ -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,

View File

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

View File

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