Mark system as unhealthy on OSError Bad message errors (#4750)

* Bad message error marks system as unhealthy

* Finish adding test cases for changes

* Rename test file for uniqueness

* bad_message to oserror_bad_message

* Omit some checks and check for network mounts
This commit is contained in:
Mike Degatano 2023-12-21 12:05:29 -05:00 committed by GitHub
parent b7ddfba71d
commit 3cc6bd19ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 481 additions and 28 deletions

View File

@ -3,6 +3,7 @@ import asyncio
from collections.abc import Awaitable from collections.abc import Awaitable
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
import errno
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
from pathlib import Path, PurePath from pathlib import Path, PurePath
@ -72,6 +73,7 @@ from ..hardware.data import Device
from ..homeassistant.const import WSEvent, WSType from ..homeassistant.const import WSEvent, WSType
from ..jobs.const import JobExecutionLimit from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from ..store.addon import AddonStore from ..store.addon import AddonStore
from ..utils import check_port from ..utils import check_port
from ..utils.apparmor import adjust_profile from ..utils.apparmor import adjust_profile
@ -793,6 +795,8 @@ class Addon(AddonModel):
try: try:
self.path_pulse.write_text(pulse_config, encoding="utf-8") self.path_pulse.write_text(pulse_config, encoding="utf-8")
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error( _LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err "Add-on %s can't write pulse/client.config: %s", self.slug, err
) )

View File

@ -1,5 +1,6 @@
"""Backups RESTful API.""" """Backups RESTful API."""
import asyncio import asyncio
import errno
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
@ -36,6 +37,7 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..mounts.const import MountUsage from ..mounts.const import MountUsage
from ..resolution.const import UnhealthyReason
from .const import CONTENT_TYPE_TAR from .const import CONTENT_TYPE_TAR
from .utils import api_process, api_validate from .utils import api_process, api_validate
@ -288,6 +290,8 @@ class APIBackups(CoreSysAttributes):
backup.write(chunk) backup.write(chunk)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't write new backup file: %s", err) _LOGGER.error("Can't write new backup file: %s", err)
return False return False

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Iterable from collections.abc import Awaitable, Iterable
import errno
import logging import logging
from pathlib import Path from pathlib import Path
@ -19,6 +20,7 @@ from ..jobs.const import JOB_GROUP_BACKUP_MANAGER, JobCondition, JobExecutionLim
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup from ..jobs.job_group import JobGroup
from ..mounts.mount import Mount from ..mounts.mount import Mount
from ..resolution.const import UnhealthyReason
from ..utils.common import FileConfiguration from ..utils.common import FileConfiguration
from ..utils.dt import utcnow from ..utils.dt import utcnow
from ..utils.sentinel import DEFAULT from ..utils.sentinel import DEFAULT
@ -31,18 +33,6 @@ from .validate import ALL_FOLDERS, SCHEMA_BACKUPS_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
def _list_backup_files(path: Path) -> Iterable[Path]:
"""Return iterable of backup files, suppress and log OSError for network mounts."""
try:
# is_dir does a stat syscall which raises if the mount is down
if path.is_dir():
return path.glob("*.tar")
except OSError as err:
_LOGGER.error("Could not list backups from %s: %s", path.as_posix(), err)
return []
class BackupManager(FileConfiguration, JobGroup): class BackupManager(FileConfiguration, JobGroup):
"""Manage backups.""" """Manage backups."""
@ -119,6 +109,19 @@ class BackupManager(FileConfiguration, JobGroup):
) )
self.sys_jobs.current.stage = stage self.sys_jobs.current.stage = stage
def _list_backup_files(self, path: Path) -> Iterable[Path]:
"""Return iterable of backup files, suppress and log OSError for network mounts."""
try:
# is_dir does a stat syscall which raises if the mount is down
if path.is_dir():
return path.glob("*.tar")
except OSError as err:
if err.errno == errno.EBADMSG and path == self.sys_config.path_backup:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Could not list backups from %s: %s", path.as_posix(), err)
return []
def _create_backup( def _create_backup(
self, self,
name: str, name: str,
@ -169,7 +172,7 @@ class BackupManager(FileConfiguration, JobGroup):
tasks = [ tasks = [
self.sys_create_task(_load_backup(tar_file)) self.sys_create_task(_load_backup(tar_file))
for path in self.backup_locations for path in self.backup_locations
for tar_file in _list_backup_files(path) for tar_file in self._list_backup_files(path)
] ]
_LOGGER.info("Found %d backup files", len(tasks)) _LOGGER.info("Found %d backup files", len(tasks))
@ -184,6 +187,11 @@ class BackupManager(FileConfiguration, JobGroup):
_LOGGER.info("Removed backup file %s", backup.slug) _LOGGER.info("Removed backup file %s", backup.slug)
except OSError as err: except OSError as err:
if (
err.errno == errno.EBADMSG
and backup.tarfile.parent == self.sys_config.path_backup
):
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't remove backup %s: %s", backup.slug, err) _LOGGER.error("Can't remove backup %s: %s", backup.slug, err)
return False return False
@ -208,6 +216,8 @@ class BackupManager(FileConfiguration, JobGroup):
backup.tarfile.rename(tar_origin) backup.tarfile.rename(tar_origin)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't move backup file to storage: %s", err) _LOGGER.error("Can't move backup file to storage: %s", err)
return None return None

View File

@ -1,6 +1,7 @@
"""Home Assistant control object.""" """Home Assistant control object."""
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import errno
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
from pathlib import Path, PurePath from pathlib import Path, PurePath
@ -42,6 +43,7 @@ from ..exceptions import (
from ..hardware.const import PolicyGroup from ..hardware.const import PolicyGroup
from ..hardware.data import Device from ..hardware.data import Device
from ..jobs.decorator import Job, JobExecutionLimit from ..jobs.decorator import Job, JobExecutionLimit
from ..resolution.const import UnhealthyReason
from ..utils import remove_folder from ..utils import remove_folder
from ..utils.common import FileConfiguration from ..utils.common import FileConfiguration
from ..utils.json import read_json_file, write_json_file from ..utils.json import read_json_file, write_json_file
@ -300,6 +302,8 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
try: try:
self.path_pulse.write_text(pulse_config, encoding="utf-8") self.path_pulse.write_text(pulse_config, encoding="utf-8")
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Home Assistant can't write pulse/client.config: %s", err) _LOGGER.error("Home Assistant can't write pulse/client.config: %s", err)
else: else:
_LOGGER.info("Update pulse/client.config: %s", self.path_pulse) _LOGGER.info("Update pulse/client.config: %s", self.path_pulse)

View File

@ -1,6 +1,7 @@
"""AppArmor control for host.""" """AppArmor control for host."""
from __future__ import annotations from __future__ import annotations
import errno
import logging import logging
from pathlib import Path from pathlib import Path
import shutil import shutil
@ -9,7 +10,7 @@ from awesomeversion import AwesomeVersion
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import DBusError, HostAppArmorError from ..exceptions import DBusError, HostAppArmorError
from ..resolution.const import UnsupportedReason from ..resolution.const import UnhealthyReason, UnsupportedReason
from ..utils.apparmor import validate_profile from ..utils.apparmor import validate_profile
from .const import HostFeature from .const import HostFeature
@ -80,6 +81,8 @@ class AppArmorControl(CoreSysAttributes):
try: try:
await self.sys_run_in_executor(shutil.copyfile, profile_file, dest_profile) await self.sys_run_in_executor(shutil.copyfile, profile_file, dest_profile)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HostAppArmorError( raise HostAppArmorError(
f"Can't copy {profile_file}: {err}", _LOGGER.error f"Can't copy {profile_file}: {err}", _LOGGER.error
) from err ) from err
@ -103,6 +106,8 @@ class AppArmorControl(CoreSysAttributes):
try: try:
await self.sys_run_in_executor(profile_file.unlink) await self.sys_run_in_executor(profile_file.unlink)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HostAppArmorError( raise HostAppArmorError(
f"Can't remove profile: {err}", _LOGGER.error f"Can't remove profile: {err}", _LOGGER.error
) from err ) from err
@ -117,6 +122,8 @@ class AppArmorControl(CoreSysAttributes):
try: try:
await self.sys_run_in_executor(shutil.copy, profile_file, backup_file) await self.sys_run_in_executor(shutil.copy, profile_file, backup_file)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HostAppArmorError( raise HostAppArmorError(
f"Can't backup profile {profile_name}: {err}", _LOGGER.error f"Can't backup profile {profile_name}: {err}", _LOGGER.error
) from err ) from err

View File

@ -1,5 +1,6 @@
"""OS support on supervisor.""" """OS support on supervisor."""
from collections.abc import Awaitable from collections.abc import Awaitable
import errno
import logging import logging
from pathlib import Path from pathlib import Path
@ -13,6 +14,7 @@ from ..dbus.rauc import RaucState
from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError
from ..jobs.const import JobCondition, JobExecutionLimit from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from .data_disk import DataDisk from .data_disk import DataDisk
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -120,6 +122,8 @@ class OSManager(CoreSysAttributes):
) from err ) from err
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HassOSUpdateError( raise HassOSUpdateError(
f"Can't write OTA file: {err!s}", _LOGGER.error f"Can't write OTA file: {err!s}", _LOGGER.error
) from err ) from err

View File

@ -4,6 +4,7 @@ Code: https://github.com/home-assistant/plugin-audio
""" """
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
import errno
import logging import logging
from pathlib import Path, PurePath from pathlib import Path, PurePath
import shutil import shutil
@ -25,6 +26,7 @@ from ..exceptions import (
) )
from ..jobs.const import JobExecutionLimit from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from ..utils.json import write_json_file from ..utils.json import write_json_file
from ..utils.sentry import capture_exception from ..utils.sentry import capture_exception
from .base import PluginBase from .base import PluginBase
@ -83,6 +85,9 @@ class PluginAudio(PluginBase):
PULSE_CLIENT_TMPL.read_text(encoding="utf-8") PULSE_CLIENT_TMPL.read_text(encoding="utf-8")
) )
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read pulse-client.tmpl: %s", err) _LOGGER.error("Can't read pulse-client.tmpl: %s", err)
await super().load() await super().load()
@ -93,6 +98,8 @@ class PluginAudio(PluginBase):
try: try:
shutil.copy(ASOUND_TMPL, asound) shutil.copy(ASOUND_TMPL, asound)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't create default asound: %s", err) _LOGGER.error("Can't create default asound: %s", err)
async def install(self) -> None: async def install(self) -> None:

View File

@ -4,6 +4,7 @@ Code: https://github.com/home-assistant/plugin-dns
""" """
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
import errno
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
from pathlib import Path from pathlib import Path
@ -29,7 +30,7 @@ from ..exceptions import (
) )
from ..jobs.const import JobExecutionLimit from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..resolution.const import ContextType, IssueType, SuggestionType from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.json import write_json_file from ..utils.json import write_json_file
from ..utils.sentry import capture_exception from ..utils.sentry import capture_exception
from ..validate import dns_url from ..validate import dns_url
@ -146,12 +147,16 @@ class PluginDns(PluginBase):
RESOLV_TMPL.read_text(encoding="utf-8") RESOLV_TMPL.read_text(encoding="utf-8")
) )
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read resolve.tmpl: %s", err) _LOGGER.error("Can't read resolve.tmpl: %s", err)
try: try:
self.hosts_template = jinja2.Template( self.hosts_template = jinja2.Template(
HOSTS_TMPL.read_text(encoding="utf-8") HOSTS_TMPL.read_text(encoding="utf-8")
) )
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read hosts.tmpl: %s", err) _LOGGER.error("Can't read hosts.tmpl: %s", err)
await self._init_hosts() await self._init_hosts()
@ -364,6 +369,8 @@ class PluginDns(PluginBase):
self.hosts.write_text, data, encoding="utf-8" self.hosts.write_text, data, encoding="utf-8"
) )
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise CoreDNSError(f"Can't update hosts: {err}", _LOGGER.error) from err raise CoreDNSError(f"Can't update hosts: {err}", _LOGGER.error) from err
async def add_host( async def add_host(
@ -436,6 +443,12 @@ class PluginDns(PluginBase):
def _write_resolv(self, resolv_conf: Path) -> None: def _write_resolv(self, resolv_conf: Path) -> None:
"""Update/Write resolv.conf file.""" """Update/Write resolv.conf file."""
if not self.resolv_template:
_LOGGER.warning(
"Resolv template is missing, cannot write/update %s", resolv_conf
)
return
nameservers = [str(self.sys_docker.network.dns), "127.0.0.11"] nameservers = [str(self.sys_docker.network.dns), "127.0.0.11"]
# Read resolv config # Read resolv config
@ -445,6 +458,8 @@ class PluginDns(PluginBase):
try: try:
resolv_conf.write_text(data) resolv_conf.write_text(data)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.warning("Can't write/update %s: %s", resolv_conf, err) _LOGGER.warning("Can't write/update %s: %s", resolv_conf, err)
return return

View File

@ -59,9 +59,10 @@ class UnhealthyReason(StrEnum):
"""Reasons for unsupported status.""" """Reasons for unsupported status."""
DOCKER = "docker" DOCKER = "docker"
OSERROR_BAD_MESSAGE = "oserror_bad_message"
PRIVILEGED = "privileged"
SUPERVISOR = "supervisor" SUPERVISOR = "supervisor"
SETUP = "setup" SETUP = "setup"
PRIVILEGED = "privileged"
UNTRUSTED = "untrusted" UNTRUSTED = "untrusted"

View File

@ -1,4 +1,5 @@
"""Evaluation class for Content Trust.""" """Evaluation class for Content Trust."""
import errno
import logging import logging
from pathlib import Path from pathlib import Path
@ -6,7 +7,7 @@ from ...const import CoreState
from ...coresys import CoreSys from ...coresys import CoreSys
from ...exceptions import CodeNotaryError, CodeNotaryUntrusted from ...exceptions import CodeNotaryError, CodeNotaryUntrusted
from ...utils.codenotary import calc_checksum_path_sourcecode from ...utils.codenotary import calc_checksum_path_sourcecode
from ..const import ContextType, IssueType, UnsupportedReason from ..const import ContextType, IssueType, UnhealthyReason, UnsupportedReason
from .base import EvaluateBase from .base import EvaluateBase
_SUPERVISOR_SOURCE = Path("/usr/src/supervisor/supervisor") _SUPERVISOR_SOURCE = Path("/usr/src/supervisor/supervisor")
@ -48,6 +49,9 @@ class EvaluateSourceMods(EvaluateBase):
calc_checksum_path_sourcecode, _SUPERVISOR_SOURCE calc_checksum_path_sourcecode, _SUPERVISOR_SOURCE
) )
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM
) )

View File

@ -1,5 +1,6 @@
"""Init file for Supervisor add-on data.""" """Init file for Supervisor add-on data."""
from dataclasses import dataclass from dataclasses import dataclass
import errno
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -19,7 +20,7 @@ from ..const import (
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError from ..exceptions import ConfigurationFileError
from ..resolution.const import ContextType, IssueType, SuggestionType from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.common import find_one_filetype, read_json_or_yaml_file from ..utils.common import find_one_filetype, read_json_or_yaml_file
from ..utils.json import read_json_file from ..utils.json import read_json_file
from .const import StoreType from .const import StoreType
@ -157,7 +158,9 @@ class StoreData(CoreSysAttributes):
addon_list = await self.sys_run_in_executor(_get_addons_list) addon_list = await self.sys_run_in_executor(_get_addons_list)
except OSError as err: except OSError as err:
suggestion = None suggestion = None
if path.stem != StoreType.LOCAL: if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
elif path.stem != StoreType.LOCAL:
suggestion = [SuggestionType.EXECUTE_RESET] suggestion = [SuggestionType.EXECUTE_RESET]
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY, IssueType.CORRUPT_REPOSITORY,

View File

@ -2,6 +2,7 @@
from collections.abc import Awaitable from collections.abc import Awaitable
from contextlib import suppress from contextlib import suppress
from datetime import timedelta from datetime import timedelta
import errno
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
from pathlib import Path from pathlib import Path
@ -27,7 +28,7 @@ from .exceptions import (
) )
from .jobs.const import JobCondition, JobExecutionLimit from .jobs.const import JobCondition, JobExecutionLimit
from .jobs.decorator import Job from .jobs.decorator import Job
from .resolution.const import ContextType, IssueType from .resolution.const import ContextType, IssueType, UnhealthyReason
from .utils.codenotary import calc_checksum from .utils.codenotary import calc_checksum
from .utils.sentry import capture_exception from .utils.sentry import capture_exception
@ -155,6 +156,8 @@ class Supervisor(CoreSysAttributes):
try: try:
profile_file.write_text(data, encoding="utf-8") profile_file.write_text(data, encoding="utf-8")
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise SupervisorAppArmorError( raise SupervisorAppArmorError(
f"Can't write temporary profile: {err!s}", _LOGGER.error f"Can't write temporary profile: {err!s}", _LOGGER.error
) from err ) from err

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import errno
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch from unittest.mock import MagicMock, PropertyMock, patch
@ -696,3 +697,27 @@ async def test_local_example_ingress_port_set(
await install_addon_example.load() await install_addon_example.load()
assert install_addon_example.ingress_port != 0 assert install_addon_example.ingress_port != 0
def test_addon_pulse_error(
coresys: CoreSys,
install_addon_example: Addon,
caplog: pytest.LogCaptureFixture,
tmp_supervisor_data,
):
"""Test error writing pulse config for addon."""
with patch(
"supervisor.addons.addon.Path.write_text", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
install_addon_example.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
install_addon_example.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False

View File

@ -1,6 +1,8 @@
"""Test BackupManager class.""" """Test BackupManager class."""
import asyncio import asyncio
import errno
from pathlib import Path
from shutil import rmtree from shutil import rmtree
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, patch
@ -1555,3 +1557,82 @@ async def test_skip_homeassistant_database(
assert read_json_file(test_db) == {"hello": "world"} assert read_json_file(test_db) == {"hello": "world"}
assert read_json_file(test_db_wal) == {"hello": "world"} assert read_json_file(test_db_wal) == {"hello": "world"}
assert not test_db_shm.exists() assert not test_db_shm.exists()
@pytest.mark.parametrize(
"tar_parent,healthy_expected",
[
(Path("/data/mounts/test"), True),
(Path("/data/backup"), False),
],
)
def test_backup_remove_error(
coresys: CoreSys,
full_backup_mock: Backup,
tar_parent: Path,
healthy_expected: bool,
):
"""Test removing a backup error."""
full_backup_mock.tarfile.unlink.side_effect = (err := OSError())
full_backup_mock.tarfile.parent = tar_parent
err.errno = errno.EBUSY
assert coresys.backups.remove(full_backup_mock) is False
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
assert coresys.backups.remove(full_backup_mock) is False
assert coresys.core.healthy is healthy_expected
@pytest.mark.parametrize(
"error_path,healthy_expected",
[(Path("/data/backup"), False), (Path("/data/mounts/backup_test"), True)],
)
async def test_reload_error(
coresys: CoreSys,
caplog: pytest.LogCaptureFixture,
error_path: Path,
healthy_expected: bool,
path_extern,
mount_propagation,
):
"""Test error during reload."""
err = OSError()
def mock_is_dir(path: Path) -> bool:
"""Mock of is_dir."""
if path == error_path:
raise err
return True
# 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",
},
)
)
with patch("supervisor.backups.manager.Path.is_dir", new=mock_is_dir), patch(
"supervisor.backups.manager.Path.glob", return_value=[]
):
err.errno = errno.EBUSY
await coresys.backups.reload()
assert "Could not list backups" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.backups.reload()
assert "Could not list backups" in caplog.text
assert coresys.core.healthy is healthy_expected

View File

@ -1,12 +1,15 @@
"""Test hardware utils.""" """Test hardware utils."""
# pylint: disable=protected-access import errno
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from pytest import LogCaptureFixture
from supervisor.coresys import CoreSys
from supervisor.hardware.data import Device from supervisor.hardware.data import Device
def test_have_audio(coresys): def test_have_audio(coresys: CoreSys):
"""Test usb device filter.""" """Test usb device filter."""
assert not coresys.hardware.helper.support_audio assert not coresys.hardware.helper.support_audio
@ -26,7 +29,7 @@ def test_have_audio(coresys):
assert coresys.hardware.helper.support_audio assert coresys.hardware.helper.support_audio
def test_have_usb(coresys): def test_have_usb(coresys: CoreSys):
"""Test usb device filter.""" """Test usb device filter."""
assert not coresys.hardware.helper.support_usb assert not coresys.hardware.helper.support_usb
@ -46,7 +49,7 @@ def test_have_usb(coresys):
assert coresys.hardware.helper.support_usb assert coresys.hardware.helper.support_usb
def test_have_gpio(coresys): def test_have_gpio(coresys: CoreSys):
"""Test usb device filter.""" """Test usb device filter."""
assert not coresys.hardware.helper.support_gpio assert not coresys.hardware.helper.support_gpio
@ -66,7 +69,7 @@ def test_have_gpio(coresys):
assert coresys.hardware.helper.support_gpio assert coresys.hardware.helper.support_gpio
def test_hide_virtual_device(coresys): def test_hide_virtual_device(coresys: CoreSys):
"""Test hidding virtual devices.""" """Test hidding virtual devices."""
udev_device = MagicMock() udev_device = MagicMock()
@ -81,3 +84,15 @@ def test_hide_virtual_device(coresys):
udev_device.sys_path = "/sys/devices/virtual/vc/vcs1" udev_device.sys_path = "/sys/devices/virtual/vc/vcs1"
assert coresys.hardware.helper.hide_virtual_device(udev_device) assert coresys.hardware.helper.hide_virtual_device(udev_device)
def test_last_boot_error(coresys: CoreSys, caplog: LogCaptureFixture):
"""Test error reading last boot."""
with patch(
"supervisor.hardware.helper.Path.read_text", side_effect=(err := OSError())
):
err.errno = errno.EBADMSG
assert coresys.hardware.helper.last_boot is None
assert coresys.core.healthy is True
assert "Can't read stat data" in caplog.text

View File

@ -1,9 +1,12 @@
"""Test Homeassistant module.""" """Test Homeassistant module."""
import asyncio import asyncio
import errno
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from pytest import LogCaptureFixture
from supervisor.const import CoreState from supervisor.const import CoreState
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.interface import DockerInterface from supervisor.docker.interface import DockerInterface
@ -44,3 +47,23 @@ async def test_get_users_none(coresys: CoreSys, ha_ws_client: AsyncMock):
assert [] == await coresys.homeassistant.get_users.__wrapped__( assert [] == await coresys.homeassistant.get_users.__wrapped__(
coresys.homeassistant coresys.homeassistant
) )
def test_write_pulse_error(coresys: CoreSys, caplog: LogCaptureFixture):
"""Test errors writing pulse config."""
with patch(
"supervisor.homeassistant.module.Path.write_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBUSY
coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False

View File

@ -0,0 +1,60 @@
"""Test host apparmor control."""
import errno
from pathlib import Path
from unittest.mock import patch
from pytest import raises
from supervisor.coresys import CoreSys
from supervisor.exceptions import HostAppArmorError
async def test_load_profile_error(coresys: CoreSys):
"""Test error loading apparmor profile."""
test_path = Path("test")
with patch("supervisor.host.apparmor.validate_profile"), patch(
"supervisor.host.apparmor.shutil.copyfile", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
with raises(HostAppArmorError):
await coresys.host.apparmor.load_profile("test", test_path)
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with raises(HostAppArmorError):
await coresys.host.apparmor.load_profile("test", test_path)
assert coresys.core.healthy is False
async def test_remove_profile_error(coresys: CoreSys, path_extern):
"""Test error removing apparmor profile."""
coresys.host.apparmor._profiles.add("test") # pylint: disable=protected-access
with patch("supervisor.host.apparmor.Path.unlink", side_effect=(err := OSError())):
err.errno = errno.EBUSY
with raises(HostAppArmorError):
await coresys.host.apparmor.remove_profile("test")
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with raises(HostAppArmorError):
await coresys.host.apparmor.remove_profile("test")
assert coresys.core.healthy is False
async def test_backup_profile_error(coresys: CoreSys, path_extern):
"""Test error while backing up apparmor profile."""
test_path = Path("test")
coresys.host.apparmor._profiles.add("test") # pylint: disable=protected-access
with patch(
"supervisor.host.apparmor.shutil.copyfile", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
with raises(HostAppArmorError):
await coresys.host.apparmor.backup_profile("test", test_path)
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with raises(HostAppArmorError):
await coresys.host.apparmor.backup_profile("test", test_path)
assert coresys.core.healthy is False

View File

@ -1,4 +1,5 @@
"""Test audio plugin.""" """Test audio plugin."""
import errno
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@ -56,3 +57,26 @@ async def test_config_write(
"debug": True, "debug": True,
}, },
) )
async def test_load_error(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error reading config file during load."""
with patch(
"supervisor.plugins.audio.Path.read_text", side_effect=(err := OSError())
), patch("supervisor.plugins.audio.shutil.copy", side_effect=err):
err.errno = errno.EBUSY
await coresys.plugins.audio.load()
assert "Can't read pulse-client.tmpl" in caplog.text
assert "Can't create default asound" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.audio.load()
assert "Can't read pulse-client.tmpl" in caplog.text
assert "Can't create default asound" in caplog.text
assert coresys.core.healthy is False

View File

@ -1,5 +1,6 @@
"""Test DNS plugin.""" """Test DNS plugin."""
import asyncio import asyncio
import errno
from ipaddress import IPv4Address from ipaddress import IPv4Address
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@ -183,3 +184,49 @@ async def test_loop_detection_on_failure(coresys: CoreSys):
Suggestion(SuggestionType.EXECUTE_RESET, ContextType.PLUGIN, "dns") Suggestion(SuggestionType.EXECUTE_RESET, ContextType.PLUGIN, "dns")
] ]
rebuild.assert_called_once() rebuild.assert_called_once()
async def test_load_error(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error reading config files during load."""
with patch(
"supervisor.plugins.dns.Path.read_text", side_effect=(err := OSError())
), patch("supervisor.plugins.dns.Path.write_text", side_effect=err):
err.errno = errno.EBUSY
await coresys.plugins.dns.load()
assert "Can't read resolve.tmpl" in caplog.text
assert "Can't read hosts.tmpl" in caplog.text
assert "Resolv template is missing" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.dns.load()
assert "Can't read resolve.tmpl" in caplog.text
assert "Can't read hosts.tmpl" in caplog.text
assert "Resolv template is missing" in caplog.text
assert coresys.core.healthy is False
async def test_load_error_writing_resolv(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error writing resolv during load."""
with patch(
"supervisor.plugins.dns.Path.write_text", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
await coresys.plugins.dns.load()
assert "Can't write/update /etc/resolv.conf" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.dns.load()
assert "Can't write/update /etc/resolv.conf" in caplog.text
assert coresys.core.healthy is False

View File

@ -1,5 +1,6 @@
"""Test evaluation base.""" """Test evaluation base."""
# pylint: disable=import-error,protected-access # pylint: disable=import-error,protected-access
import errno
from unittest.mock import patch from unittest.mock import patch
from supervisor.const import CoreState from supervisor.const import CoreState
@ -50,3 +51,20 @@ async def test_did_run(coresys: CoreSys):
await apparmor() await apparmor()
evaluate.assert_not_called() evaluate.assert_not_called()
evaluate.reset_mock() evaluate.reset_mock()
async def test_evaluation_error(coresys: CoreSys):
"""Test error reading file during evaluation."""
apparmor = EvaluateAppArmor(coresys)
coresys.core.state = CoreState.INITIALIZE
assert apparmor.reason not in coresys.resolution.unsupported
with patch(
"supervisor.resolution.evaluations.apparmor.Path.read_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBADMSG
await apparmor()
assert apparmor.reason in coresys.resolution.unsupported
assert coresys.core.healthy is True

View File

@ -1,5 +1,6 @@
"""Test evaluation base.""" """Test evaluation base."""
# pylint: disable=import-error,protected-access # pylint: disable=import-error,protected-access
import errno
import os import os
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -7,6 +8,8 @@ from unittest.mock import AsyncMock, patch
from supervisor.const import CoreState from supervisor.const import CoreState
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import CodeNotaryError, CodeNotaryUntrusted from supervisor.exceptions import CodeNotaryError, CodeNotaryUntrusted
from supervisor.resolution.const import ContextType, IssueType
from supervisor.resolution.data import Issue
from supervisor.resolution.evaluations.source_mods import EvaluateSourceMods from supervisor.resolution.evaluations.source_mods import EvaluateSourceMods
@ -56,3 +59,30 @@ async def test_did_run(coresys: CoreSys):
await sourcemods() await sourcemods()
evaluate.assert_not_called() evaluate.assert_not_called()
evaluate.reset_mock() evaluate.reset_mock()
async def test_evaluation_error(coresys: CoreSys):
"""Test error reading file during evaluation."""
sourcemods = EvaluateSourceMods(coresys)
coresys.core.state = CoreState.RUNNING
corrupt_fs = Issue(IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM)
assert sourcemods.reason not in coresys.resolution.unsupported
assert corrupt_fs not in coresys.resolution.issues
with patch(
"supervisor.utils.codenotary.dirhash",
side_effect=(err := OSError()),
):
err.errno = errno.EBUSY
await sourcemods()
assert sourcemods.reason not in coresys.resolution.unsupported
assert corrupt_fs in coresys.resolution.issues
assert coresys.core.healthy is True
coresys.resolution.dismiss_issue(corrupt_fs)
err.errno = errno.EBADMSG
await sourcemods()
assert sourcemods.reason not in coresys.resolution.unsupported
assert corrupt_fs in coresys.resolution.issues
assert coresys.core.healthy is False

View File

@ -1,8 +1,13 @@
"""Test that we are reading add-on files correctly.""" """Test that we are reading add-on files correctly."""
import errno
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
# pylint: disable=protected-access
async def test_read_addon_files(coresys: CoreSys): async def test_read_addon_files(coresys: CoreSys):
@ -23,3 +28,23 @@ async def test_read_addon_files(coresys: CoreSys):
assert len(addon_list) == 1 assert len(addon_list) == 1
assert str(addon_list[0]) == "addon/config.yml" assert str(addon_list[0]) == "addon/config.yml"
async def test_reading_addon_files_error(coresys: CoreSys):
"""Test error trying to read addon files."""
corrupt_repo = Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test")
reset_repo = Suggestion(SuggestionType.EXECUTE_RESET, ContextType.STORE, "test")
with patch("pathlib.Path.glob", side_effect=(err := OSError())):
err.errno = errno.EBUSY
assert (await coresys.store.data._find_addons(Path("test"), {})) is None
assert corrupt_repo in coresys.resolution.issues
assert reset_repo in coresys.resolution.suggestions
assert coresys.core.healthy is True
coresys.resolution.dismiss_issue(corrupt_repo)
err.errno = errno.EBADMSG
assert (await coresys.store.data._find_addons(Path("test"), {})) is None
assert corrupt_repo in coresys.resolution.issues
assert reset_repo not in coresys.resolution.suggestions
assert coresys.core.healthy is False

View File

@ -1,8 +1,11 @@
"""Testing handling with CoreState.""" """Testing handling with CoreState."""
# pylint: disable=W0212 # pylint: disable=W0212
import datetime import datetime
import errno
from unittest.mock import AsyncMock, PropertyMock, patch from unittest.mock import AsyncMock, PropertyMock, patch
from pytest import LogCaptureFixture
from supervisor.const import CoreState from supervisor.const import CoreState
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import WhoamiSSLError from supervisor.exceptions import WhoamiSSLError
@ -14,7 +17,6 @@ from supervisor.utils.whoami import WhoamiData
def test_write_state(run_dir, coresys: CoreSys): def test_write_state(run_dir, coresys: CoreSys):
"""Test write corestate to /run/supervisor.""" """Test write corestate to /run/supervisor."""
coresys.core.state = CoreState.RUNNING coresys.core.state = CoreState.RUNNING
assert run_dir.read_text() == CoreState.RUNNING assert run_dir.read_text() == CoreState.RUNNING
@ -77,3 +79,16 @@ async def test_adjust_system_datetime_if_time_behind(coresys: CoreSys):
mock_retrieve_whoami.assert_called_once() mock_retrieve_whoami.assert_called_once()
mock_set_datetime.assert_called_once() mock_set_datetime.assert_called_once()
mock_check_connectivity.assert_called_once() mock_check_connectivity.assert_called_once()
def test_write_state_failure(run_dir, coresys: CoreSys, caplog: LogCaptureFixture):
"""Test failure to write corestate to /run/supervisor."""
with patch(
"supervisor.core.RUN_SUPERVISOR_STATE.write_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBADMSG
coresys.core.state = CoreState.RUNNING
assert "Can't update the Supervisor state" in caplog.text
assert coresys.core.healthy is True

View File

@ -1,6 +1,7 @@
"""Test supervisor object.""" """Test supervisor object."""
from datetime import timedelta from datetime import timedelta
import errno
from unittest.mock import AsyncMock, Mock, PropertyMock, patch from unittest.mock import AsyncMock, Mock, PropertyMock, patch
from aiohttp import ClientTimeout from aiohttp import ClientTimeout
@ -11,7 +12,11 @@ import pytest
from supervisor.const import UpdateChannel from supervisor.const import UpdateChannel
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.supervisor import DockerSupervisor from supervisor.docker.supervisor import DockerSupervisor
from supervisor.exceptions import DockerError, SupervisorUpdateError from supervisor.exceptions import (
DockerError,
SupervisorAppArmorError,
SupervisorUpdateError,
)
from supervisor.host.apparmor import AppArmorControl from supervisor.host.apparmor import AppArmorControl
from supervisor.resolution.const import ContextType, IssueType from supervisor.resolution.const import ContextType, IssueType
from supervisor.resolution.data import Issue from supervisor.resolution.data import Issue
@ -108,3 +113,22 @@ async def test_update_apparmor(
timeout=ClientTimeout(total=10), timeout=ClientTimeout(total=10),
) )
load_profile.assert_called_once() load_profile.assert_called_once()
async def test_update_apparmor_error(coresys: CoreSys, tmp_supervisor_data):
"""Test error updating apparmor profile."""
with patch("supervisor.coresys.aiohttp.ClientSession.get") as get, patch.object(
AppArmorControl, "load_profile"
), patch("supervisor.supervisor.Path.write_text", side_effect=(err := OSError())):
get.return_value.__aenter__.return_value.status = 200
get.return_value.__aenter__.return_value.text = AsyncMock(return_value="")
err.errno = errno.EBUSY
with pytest.raises(SupervisorAppArmorError):
await coresys.supervisor.update_apparmor()
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with pytest.raises(SupervisorAppArmorError):
await coresys.supervisor.update_apparmor()
assert coresys.core.healthy is False