Network backups skip free space check (#4563)

This commit is contained in:
Mike Degatano 2023-09-19 10:28:39 -04:00 committed by GitHub
parent e1232bc9e7
commit dcf024387b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 118 additions and 42 deletions

View File

@ -272,7 +272,7 @@ class BackupManager(FileConfiguration, JobGroup):
@Job( @Job(
name="backup_manager_full_backup", name="backup_manager_full_backup",
conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING], conditions=[JobCondition.RUNNING],
limit=JobExecutionLimit.GROUP_ONCE, limit=JobExecutionLimit.GROUP_ONCE,
on_condition=BackupJobError, on_condition=BackupJobError,
) )
@ -284,6 +284,11 @@ class BackupManager(FileConfiguration, JobGroup):
location: Mount | type[DEFAULT] | None = DEFAULT, location: Mount | type[DEFAULT] | None = DEFAULT,
) -> Backup | None: ) -> Backup | None:
"""Create a full backup.""" """Create a full backup."""
if self._get_base_path(location) == self.sys_config.path_backup:
await Job.check_conditions(
self, {JobCondition.FREE_SPACE}, "BackupManager.do_backup_full"
)
backup = self._create_backup( backup = self._create_backup(
name, BackupType.FULL, password, compressed, location name, BackupType.FULL, password, compressed, location
) )
@ -298,7 +303,7 @@ class BackupManager(FileConfiguration, JobGroup):
@Job( @Job(
name="backup_manager_partial_backup", name="backup_manager_partial_backup",
conditions=[JobCondition.FREE_SPACE, JobCondition.RUNNING], conditions=[JobCondition.RUNNING],
limit=JobExecutionLimit.GROUP_ONCE, limit=JobExecutionLimit.GROUP_ONCE,
on_condition=BackupJobError, on_condition=BackupJobError,
) )
@ -313,6 +318,11 @@ class BackupManager(FileConfiguration, JobGroup):
location: Mount | type[DEFAULT] | None = DEFAULT, location: Mount | type[DEFAULT] | None = DEFAULT,
) -> Backup | None: ) -> Backup | None:
"""Create a partial backup.""" """Create a partial backup."""
if self._get_base_path(location) == self.sys_config.path_backup:
await Job.check_conditions(
self, {JobCondition.FREE_SPACE}, "BackupManager.do_backup_partial"
)
addons = addons or [] addons = addons or []
folders = folders or [] folders = folders or []

View File

@ -174,6 +174,14 @@ class Job(CoreSysAttributes):
return obj return obj
return None return None
def _handle_job_condition_exception(self, err: JobConditionException) -> None:
"""Handle a job condition failure."""
error_msg = str(err)
if self.on_condition is None:
_LOGGER.info(error_msg)
return
raise self.on_condition(error_msg, _LOGGER.warning) from None
def __call__(self, method): def __call__(self, method):
"""Call the wrapper logic.""" """Call the wrapper logic."""
self._method = method self._method = method
@ -196,13 +204,11 @@ class Job(CoreSysAttributes):
# Handle condition # Handle condition
if self.conditions: if self.conditions:
try: try:
await self._check_conditions() await Job.check_conditions(
self, set(self.conditions), self._method.__qualname__
)
except JobConditionException as err: except JobConditionException as err:
error_msg = str(err) return self._handle_job_condition_exception(err)
if self.on_condition is None:
_LOGGER.info(error_msg)
return
raise self.on_condition(error_msg, _LOGGER.warning) from None
# Handle exection limits # Handle exection limits
if self.limit in (JobExecutionLimit.SINGLE_WAIT, JobExecutionLimit.ONCE): if self.limit in (JobExecutionLimit.SINGLE_WAIT, JobExecutionLimit.ONCE):
@ -266,6 +272,11 @@ class Job(CoreSysAttributes):
) )
return await self._method(obj, *args, **kwargs) return await self._method(obj, *args, **kwargs)
# If a method has a conditional JobCondition, they must check it in the method
# These should be handled like normal JobConditions as much as possible
except JobConditionException as err:
return self._handle_job_condition_exception(err)
except HassioError as err: except HassioError as err:
raise err raise err
except Exception as err: except Exception as err:
@ -282,10 +293,13 @@ class Job(CoreSysAttributes):
return wrapper return wrapper
async def _check_conditions(self): @staticmethod
async def check_conditions(
coresys: CoreSysAttributes, conditions: set[JobCondition], method_name: str
):
"""Check conditions.""" """Check conditions."""
used_conditions = set(self.conditions) - set(self.sys_jobs.ignore_conditions) used_conditions = set(conditions) - set(coresys.sys_jobs.ignore_conditions)
ignored_conditions = set(self.conditions) & set(self.sys_jobs.ignore_conditions) ignored_conditions = set(conditions) & set(coresys.sys_jobs.ignore_conditions)
# Check if somethings is ignored # Check if somethings is ignored
if ignored_conditions: if ignored_conditions:
@ -294,93 +308,97 @@ class Job(CoreSysAttributes):
ignored_conditions, ignored_conditions,
) )
if JobCondition.HEALTHY in used_conditions and not self.sys_core.healthy: if JobCondition.HEALTHY in used_conditions and not coresys.sys_core.healthy:
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, system is not healthy - {', '.join(self.sys_resolution.unhealthy)}" f"'{method_name}' blocked from execution, system is not healthy - {', '.join(coresys.sys_resolution.unhealthy)}"
) )
if ( if (
JobCondition.RUNNING in used_conditions JobCondition.RUNNING in used_conditions
and self.sys_core.state != CoreState.RUNNING and coresys.sys_core.state != CoreState.RUNNING
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, system is not running - {self.sys_core.state!s}" f"'{method_name}' blocked from execution, system is not running - {coresys.sys_core.state!s}"
) )
if ( if (
JobCondition.FROZEN in used_conditions JobCondition.FROZEN in used_conditions
and self.sys_core.state != CoreState.FREEZE and coresys.sys_core.state != CoreState.FREEZE
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, system is not frozen - {self.sys_core.state!s}" f"'{method_name}' blocked from execution, system is not frozen - {coresys.sys_core.state!s}"
) )
if ( if (
JobCondition.FREE_SPACE in used_conditions JobCondition.FREE_SPACE in used_conditions
and self.sys_host.info.free_space < MINIMUM_FREE_SPACE_THRESHOLD and coresys.sys_host.info.free_space < MINIMUM_FREE_SPACE_THRESHOLD
): ):
self.sys_resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM) coresys.sys_resolution.create_issue(
IssueType.FREE_SPACE, ContextType.SYSTEM
)
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, not enough free space ({self.sys_host.info.free_space}GB) left on the device" f"'{method_name}' blocked from execution, not enough free space ({coresys.sys_host.info.free_space}GB) left on the device"
) )
if JobCondition.INTERNET_SYSTEM in used_conditions: if JobCondition.INTERNET_SYSTEM in used_conditions:
await self.sys_supervisor.check_connectivity() await coresys.sys_supervisor.check_connectivity()
if not self.sys_supervisor.connectivity: if not coresys.sys_supervisor.connectivity:
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, no supervisor internet connection" f"'{method_name}' blocked from execution, no supervisor internet connection"
) )
if JobCondition.INTERNET_HOST in used_conditions: if JobCondition.INTERNET_HOST in used_conditions:
await self.sys_host.network.check_connectivity() await coresys.sys_host.network.check_connectivity()
if ( if (
self.sys_host.network.connectivity is not None coresys.sys_host.network.connectivity is not None
and not self.sys_host.network.connectivity and not coresys.sys_host.network.connectivity
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, no host internet connection" f"'{method_name}' blocked from execution, no host internet connection"
) )
if JobCondition.HAOS in used_conditions and not self.sys_os.available: if JobCondition.HAOS in used_conditions and not coresys.sys_os.available:
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, no Home Assistant OS available" f"'{method_name}' blocked from execution, no Home Assistant OS available"
) )
if ( if (
JobCondition.OS_AGENT in used_conditions JobCondition.OS_AGENT in used_conditions
and HostFeature.OS_AGENT not in self.sys_host.features and HostFeature.OS_AGENT not in coresys.sys_host.features
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, no Home Assistant OS-Agent available" f"'{method_name}' blocked from execution, no Home Assistant OS-Agent available"
) )
if ( if (
JobCondition.HOST_NETWORK in used_conditions JobCondition.HOST_NETWORK in used_conditions
and not self.sys_dbus.network.is_connected and not coresys.sys_dbus.network.is_connected
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, host Network Manager not available" f"'{method_name}' blocked from execution, host Network Manager not available"
) )
if ( if (
JobCondition.AUTO_UPDATE in used_conditions JobCondition.AUTO_UPDATE in used_conditions
and not self.sys_updater.auto_update and not coresys.sys_updater.auto_update
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, supervisor auto updates disabled" f"'{method_name}' blocked from execution, supervisor auto updates disabled"
) )
if ( if (
JobCondition.SUPERVISOR_UPDATED in used_conditions JobCondition.SUPERVISOR_UPDATED in used_conditions
and self.sys_supervisor.need_update and coresys.sys_supervisor.need_update
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, supervisor needs to be updated first" f"'{method_name}' blocked from execution, supervisor needs to be updated first"
) )
if JobCondition.PLUGINS_UPDATED in used_conditions and ( if JobCondition.PLUGINS_UPDATED in used_conditions and (
out_of_date := [ out_of_date := [
plugin for plugin in self.sys_plugins.all_plugins if plugin.need_update plugin
for plugin in coresys.sys_plugins.all_plugins
if plugin.need_update
] ]
): ):
errors = await asyncio.gather( errors = await asyncio.gather(
@ -391,15 +409,15 @@ class Job(CoreSysAttributes):
out_of_date[i].slug for i in range(len(errors)) if errors[i] is not None out_of_date[i].slug for i in range(len(errors)) if errors[i] is not None
]: ]:
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, was unable to update plugin(s) {', '.join(update_failures)} and all plugins must be up to date first" f"'{method_name}' blocked from execution, was unable to update plugin(s) {', '.join(update_failures)} and all plugins must be up to date first"
) )
if ( if (
JobCondition.MOUNT_AVAILABLE in used_conditions JobCondition.MOUNT_AVAILABLE in used_conditions
and HostFeature.MOUNT not in self.sys_host.features and HostFeature.MOUNT not in coresys.sys_host.features
): ):
raise JobConditionException( raise JobConditionException(
f"'{self._method.__qualname__}' blocked from execution, mounting not supported on system" f"'{method_name}' blocked from execution, mounting not supported on system"
) )
async def _acquire_exection_limit(self) -> None: async def _acquire_exection_limit(self) -> None:

View File

@ -20,7 +20,7 @@ from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState from supervisor.docker.const import ContainerState
from supervisor.docker.homeassistant import DockerHomeAssistant from supervisor.docker.homeassistant import DockerHomeAssistant
from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import AddonsError, BackupError, DockerError from supervisor.exceptions import AddonsError, BackupError, BackupJobError, DockerError
from supervisor.homeassistant.api import HomeAssistantAPI from supervisor.homeassistant.api import HomeAssistantAPI
from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant from supervisor.homeassistant.module import HomeAssistant
@ -1393,3 +1393,51 @@ async def test_restore_new_addon(
assert await coresys.backups.do_restore_partial(backup, addons=["local_ssh"]) assert await coresys.backups.do_restore_partial(backup, addons=["local_ssh"])
assert "local_ssh" in coresys.addons.local assert "local_ssh" in coresys.addons.local
async def test_backup_to_mount_bypasses_free_space_condition(
coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock],
tmp_supervisor_data,
path_extern,
mount_propagation,
):
"""Test backing up to a mount bypasses the check on local free space."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda _: 0.1
# These fail due to lack of local free space
with pytest.raises(BackupJobError):
await coresys.backups.do_backup_full()
with pytest.raises(BackupJobError):
await coresys.backups.do_backup_partial(folders=["media"])
systemd_service: SystemdService = all_dbus_services["systemd"]
systemd_service.response_get_unit = [
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
]
# Add a backup mount
await coresys.mounts.load()
await coresys.mounts.create_mount(
Mount.from_dict(
coresys,
{
"name": "backup_test",
"usage": "backup",
"type": "cifs",
"server": "test.local",
"share": "test",
},
)
)
mount = coresys.mounts.get("backup_test")
# These succeed because local free space does not matter when using a mount
await coresys.backups.do_backup_full(location=mount)
await coresys.backups.do_backup_partial(folders=["media"], location=mount)