"""Provide persistent configuration for the backup integration.""" from __future__ import annotations from collections import defaultdict from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta from enum import StrEnum import random 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 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}" # The default time for automatic backups to run is at 04:45. # This time is chosen to be compatible with the time of the recorder's # nightly job which runs at 04:12. DEFAULT_BACKUP_TIME = dt.time(4, 45) # Randomize the start time of the backup by up to 60 minutes to avoid # all backups running at the same time. BACKUP_START_TIME_JITTER = 60 * 60 class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" agents: dict[str, StoredAgentConfig] automatic_backups_configured: bool create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None retention: StoredRetentionConfig schedule: StoredBackupSchedule @dataclass(kw_only=True) class BackupConfigData: """Represent loaded backup config data.""" agents: dict[str, AgentConfig] automatic_backups_configured: bool # only used by frontend create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None retention: RetentionConfig schedule: BackupSchedule @classmethod def from_dict(cls, data: StoredBackupConfig) -> Self: """Initialize backup config data from a dict.""" include_folders_data = data["create_backup"]["include_folders"] if include_folders_data: include_folders = [Folder(folder) for folder in include_folders_data] else: include_folders = None retention = data["retention"] if last_attempted_str := data["last_attempted_automatic_backup"]: last_attempted = dt_util.parse_datetime(last_attempted_str) else: last_attempted = None if last_attempted_str := data["last_completed_automatic_backup"]: last_completed = dt_util.parse_datetime(last_attempted_str) else: last_completed = None if time_str := data["schedule"]["time"]: time = dt_util.parse_time(time_str) 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=agents, automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], include_all_addons=data["create_backup"]["include_all_addons"], include_database=data["create_backup"]["include_database"], include_folders=include_folders, name=data["create_backup"]["name"], password=data["create_backup"]["password"], ), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, retention=RetentionConfig( copies=retention["copies"], days=retention["days"], ), schedule=BackupSchedule( days=days, recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]), state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)), time=time, ), ) def to_dict(self) -> StoredBackupConfig: """Convert backup config data to a dict.""" if self.last_attempted_automatic_backup: last_attempted = self.last_attempted_automatic_backup.isoformat() else: last_attempted = None if self.last_completed_automatic_backup: last_completed = self.last_completed_automatic_backup.isoformat() else: last_completed = None return StoredBackupConfig( agents={ agent_id: agent.to_dict() for agent_id, agent in self.agents.items() }, automatic_backups_configured=self.automatic_backups_configured, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, retention=self.retention.to_dict(), schedule=self.schedule.to_dict(), ) class BackupConfig: """Handle backup config.""" def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: """Initialize backup config.""" self.data = BackupConfigData( agents={}, automatic_backups_configured=False, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), ) self._hass = hass self._manager = manager def load(self, stored_config: StoredBackupConfig) -> None: """Load config.""" self.data = BackupConfigData.from_dict(stored_config) self.data.retention.apply(self._manager) self.data.schedule.apply(self._manager) @callback def update( self, *, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, automatic_backups_configured: bool | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" if agents is not UNDEFINED: for agent_id, agent_config in agents.items(): agent_retention = agent_config.get("retention") if agent_retention is None: new_agent_retention = None else: 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", True), 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: 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: self.data.retention = new_retention self.data.retention.apply(self._manager) if schedule is not UNDEFINED: new_schedule = BackupSchedule(**schedule) if new_schedule.to_dict() != self.data.schedule.to_dict(): self.data.schedule = new_schedule self.data.schedule.apply(self._manager) self._manager.store.save() @dataclass(kw_only=True) 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, } 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 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.""" 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, ) self._schedule_next(manager) else: LOGGER.debug("Unscheduling next automatic delete") self._unschedule_next(manager) @callback def _schedule_next( self, manager: BackupManager, ) -> None: """Schedule the next delete after days.""" self._unschedule_next(manager) async def _delete_backups(now: datetime) -> None: """Delete backups older than days.""" self._schedule_next(manager) def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return backups older than days to delete.""" # we need to check here since we await before # this filter is applied 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 ) manager.remove_next_delete_event = async_call_later( manager.hass, timedelta(days=1), _delete_backups ) @callback def _unschedule_next(self, manager: BackupManager) -> None: """Unschedule the next delete after days.""" if (remove_next_event := manager.remove_next_delete_event) is not None: remove_next_event() manager.remove_next_delete_event = None class StoredRetentionConfig(TypedDict): """Represent the stored backup retention configuration.""" copies: int | None days: int | None class RetentionParametersDict(TypedDict, total=False): """Represent the parameters for retention.""" copies: int | None days: int | None class AgentRetentionConfig(BaseRetentionConfig): """Represent an agent retention configuration.""" class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" days: list[Day] recurrence: ScheduleRecurrence state: ScheduleState time: str | None class ScheduleParametersDict(TypedDict, total=False): """Represent parameters for backup schedule.""" days: list[Day] recurrence: ScheduleRecurrence state: ScheduleState time: dt.time | None class Day(StrEnum): """Represent the day(s) in a custom schedule recurrence.""" MONDAY = "mon" TUESDAY = "tue" WEDNESDAY = "wed" THURSDAY = "thu" FRIDAY = "fri" SATURDAY = "sat" SUNDAY = "sun" class ScheduleRecurrence(StrEnum): """Represent the schedule recurrence.""" NEVER = "never" DAILY = "daily" CUSTOM_DAYS = "custom_days" class ScheduleState(StrEnum): """Represent the schedule recurrence. This is deprecated and can be remove in HA Core 2025.8. """ NEVER = "never" DAILY = "daily" MONDAY = "mon" TUESDAY = "tue" WEDNESDAY = "wed" THURSDAY = "thu" FRIDAY = "fri" SATURDAY = "sat" SUNDAY = "sun" @dataclass(kw_only=True) class BackupSchedule: """Represent the backup schedule.""" days: list[Day] = field(default_factory=list) recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER # Although no longer used, state is kept for backwards compatibility. # It can be removed in HA Core 2025.8. state: ScheduleState = ScheduleState.NEVER time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) next_automatic_backup: datetime | None = field(init=False, default=None) next_automatic_backup_additional = False @callback def apply( self, manager: BackupManager, ) -> None: """Apply a new schedule. There are only three possible recurrence types: never, daily, or custom_days """ if self.recurrence is ScheduleRecurrence.NEVER or ( self.recurrence is ScheduleRecurrence.CUSTOM_DAYS and not self.days ): self._unschedule_next(manager) return time = self.time if self.time is not None else DEFAULT_BACKUP_TIME if self.recurrence is ScheduleRecurrence.DAILY: self._schedule_next( CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), manager, ) else: # ScheduleRecurrence.CUSTOM_DAYS self._schedule_next( CRON_PATTERN_WEEKLY.format( m=time.minute, h=time.hour, d=",".join(day.value for day in self.days), ), manager, ) @callback def _schedule_next( self, cron_pattern: str, manager: BackupManager, ) -> None: """Schedule the next backup.""" self._unschedule_next(manager) now = dt_util.now() if (cron_event := self.cron_event) is None: seed_time = manager.config.data.last_completed_automatic_backup or now cron_event = self.cron_event = CronSim(cron_pattern, seed_time) next_time = next(cron_event) if next_time < now: # schedule a backup at next daily time once # if we missed the last scheduled backup time = self.time if self.time is not None else DEFAULT_BACKUP_TIME cron_event = CronSim( CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now ) next_time = next(cron_event) # reseed the cron event attribute # add a day to the next time to avoid scheduling at the same time again self.cron_event = CronSim(cron_pattern, now + timedelta(days=1)) # Compare the computed next time with the next time from the cron pattern # to determine if an additional backup has been scheduled cron_event_configured = CronSim(cron_pattern, now) next_configured_time = next(cron_event_configured) self.next_automatic_backup_additional = next_time < next_configured_time else: self.next_automatic_backup_additional = False async def _create_backup(now: datetime) -> None: """Create backup.""" manager.remove_next_backup_event = None self._schedule_next(cron_pattern, manager) # create the backup try: await manager.async_create_automatic_backup() except BackupManagerError as err: LOGGER.error("Error creating backup: %s", err) except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error creating automatic backup") if self.time is None: # randomize the start time of the backup by up to 60 minutes if the time is # not set to avoid all backups running at the same time next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) LOGGER.debug("Scheduling next automatic backup at %s", next_time) self.next_automatic_backup = next_time manager.remove_next_backup_event = async_track_point_in_time( manager.hass, _create_backup, next_time ) def to_dict(self) -> StoredBackupSchedule: """Convert backup schedule to a dict.""" return StoredBackupSchedule( days=self.days, recurrence=self.recurrence, state=self.state, time=self.time.isoformat() if self.time else None, ) @callback def _unschedule_next(self, manager: BackupManager) -> None: """Unschedule the next backup.""" self.next_automatic_backup = None if (remove_next_event := manager.remove_next_backup_event) is not None: remove_next_event() manager.remove_next_backup_event = None @dataclass(kw_only=True) class CreateBackupConfig: """Represent the config for async_create_backup.""" agent_ids: list[str] = field(default_factory=list) include_addons: list[str] | None = None include_all_addons: bool = False include_database: bool = True include_folders: list[Folder] | None = None name: str | None = None password: str | None = None def to_dict(self) -> StoredCreateBackupConfig: """Convert create backup config to a dict.""" return { "agent_ids": self.agent_ids, "include_addons": self.include_addons, "include_all_addons": self.include_all_addons, "include_database": self.include_database, "include_folders": self.include_folders, "name": self.name, "password": self.password, } class StoredCreateBackupConfig(TypedDict): """Represent the stored config for async_create_backup.""" agent_ids: list[str] include_addons: list[str] | None include_all_addons: bool include_database: bool include_folders: list[Folder] | None name: str | None password: str | None class CreateBackupParametersDict(TypedDict, total=False): """Represent the parameters for async_create_backup.""" agent_ids: list[str] include_addons: list[str] | None include_all_addons: bool include_database: bool include_folders: list[Folder] | None name: str | None password: str | None def _automatic_backups_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return automatic backups.""" return { backup_id: backup for backup_id, backup in backups.items() if backup.with_automatic_settings } async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: """Delete backups exceeding the configured retention count.""" def _delete_filter( 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 ( 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 {} 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 ) @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", }, )