mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-10 10:46:29 +00:00
Network backups skip free space check (#4563)
This commit is contained in:
parent
e1232bc9e7
commit
dcf024387b
@ -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 []
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user