From 3cc6bd19addd460aaaac4abe04c139bd38084af7 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 21 Dec 2023 12:05:29 -0500 Subject: [PATCH] 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 --- supervisor/addons/addon.py | 4 + supervisor/api/backups.py | 4 + supervisor/backups/manager.py | 36 ++++++--- supervisor/homeassistant/module.py | 4 + supervisor/host/apparmor.py | 9 ++- supervisor/os/manager.py | 4 + supervisor/plugins/audio.py | 7 ++ supervisor/plugins/dns.py | 17 +++- supervisor/resolution/const.py | 3 +- .../resolution/evaluations/source_mods.py | 6 +- supervisor/store/data.py | 7 +- supervisor/supervisor.py | 5 +- tests/addons/test_addon.py | 25 ++++++ tests/backups/test_manager.py | 81 +++++++++++++++++++ tests/hardware/test_helper.py | 27 +++++-- tests/homeassistant/test_module.py | 23 ++++++ tests/host/test_apparmor_control.py | 60 ++++++++++++++ tests/plugins/test_audio.py | 24 ++++++ tests/plugins/test_dns.py | 47 +++++++++++ .../evaluation/test_evaluate_apparmor.py | 18 +++++ .../evaluation/test_evaluate_source_mods.py | 30 +++++++ tests/store/test_reading_addons.py | 25 ++++++ tests/test_core.py | 17 +++- tests/test_supervisor.py | 26 +++++- 24 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 tests/host/test_apparmor_control.py diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 539a716fa..127893403 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Awaitable from contextlib import suppress from copy import deepcopy +import errno from ipaddress import IPv4Address import logging from pathlib import Path, PurePath @@ -72,6 +73,7 @@ from ..hardware.data import Device from ..homeassistant.const import WSEvent, WSType from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job +from ..resolution.const import UnhealthyReason from ..store.addon import AddonStore from ..utils import check_port from ..utils.apparmor import adjust_profile @@ -793,6 +795,8 @@ class Addon(AddonModel): try: self.path_pulse.write_text(pulse_config, encoding="utf-8") except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE _LOGGER.error( "Add-on %s can't write pulse/client.config: %s", self.slug, err ) diff --git a/supervisor/api/backups.py b/supervisor/api/backups.py index bbc116501..56518633b 100644 --- a/supervisor/api/backups.py +++ b/supervisor/api/backups.py @@ -1,5 +1,6 @@ """Backups RESTful API.""" import asyncio +import errno import logging from pathlib import Path import re @@ -36,6 +37,7 @@ from ..const import ( from ..coresys import CoreSysAttributes from ..exceptions import APIError from ..mounts.const import MountUsage +from ..resolution.const import UnhealthyReason from .const import CONTENT_TYPE_TAR from .utils import api_process, api_validate @@ -288,6 +290,8 @@ class APIBackups(CoreSysAttributes): backup.write(chunk) 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) return False diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index aebd7105b..510a13ee9 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Iterable +import errno import logging 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.job_group import JobGroup from ..mounts.mount import Mount +from ..resolution.const import UnhealthyReason from ..utils.common import FileConfiguration from ..utils.dt import utcnow from ..utils.sentinel import DEFAULT @@ -31,18 +33,6 @@ from .validate import ALL_FOLDERS, SCHEMA_BACKUPS_CONFIG _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): """Manage backups.""" @@ -119,6 +109,19 @@ class BackupManager(FileConfiguration, JobGroup): ) 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( self, name: str, @@ -169,7 +172,7 @@ class BackupManager(FileConfiguration, JobGroup): tasks = [ self.sys_create_task(_load_backup(tar_file)) 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)) @@ -184,6 +187,11 @@ class BackupManager(FileConfiguration, JobGroup): _LOGGER.info("Removed backup file %s", backup.slug) 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) return False @@ -208,6 +216,8 @@ class BackupManager(FileConfiguration, JobGroup): backup.tarfile.rename(tar_origin) 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) return None diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 70330cc2f..478bfa27e 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -1,6 +1,7 @@ """Home Assistant control object.""" import asyncio from datetime import timedelta +import errno from ipaddress import IPv4Address import logging from pathlib import Path, PurePath @@ -42,6 +43,7 @@ from ..exceptions import ( from ..hardware.const import PolicyGroup from ..hardware.data import Device from ..jobs.decorator import Job, JobExecutionLimit +from ..resolution.const import UnhealthyReason from ..utils import remove_folder from ..utils.common import FileConfiguration from ..utils.json import read_json_file, write_json_file @@ -300,6 +302,8 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes): try: self.path_pulse.write_text(pulse_config, encoding="utf-8") 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) else: _LOGGER.info("Update pulse/client.config: %s", self.path_pulse) diff --git a/supervisor/host/apparmor.py b/supervisor/host/apparmor.py index 86c13e68f..2d17c8f67 100644 --- a/supervisor/host/apparmor.py +++ b/supervisor/host/apparmor.py @@ -1,6 +1,7 @@ """AppArmor control for host.""" from __future__ import annotations +import errno import logging from pathlib import Path import shutil @@ -9,7 +10,7 @@ from awesomeversion import AwesomeVersion from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import DBusError, HostAppArmorError -from ..resolution.const import UnsupportedReason +from ..resolution.const import UnhealthyReason, UnsupportedReason from ..utils.apparmor import validate_profile from .const import HostFeature @@ -80,6 +81,8 @@ class AppArmorControl(CoreSysAttributes): try: await self.sys_run_in_executor(shutil.copyfile, profile_file, dest_profile) except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE raise HostAppArmorError( f"Can't copy {profile_file}: {err}", _LOGGER.error ) from err @@ -103,6 +106,8 @@ class AppArmorControl(CoreSysAttributes): try: await self.sys_run_in_executor(profile_file.unlink) except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE raise HostAppArmorError( f"Can't remove profile: {err}", _LOGGER.error ) from err @@ -117,6 +122,8 @@ class AppArmorControl(CoreSysAttributes): try: await self.sys_run_in_executor(shutil.copy, profile_file, backup_file) except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE raise HostAppArmorError( f"Can't backup profile {profile_name}: {err}", _LOGGER.error ) from err diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index 6a8867d5d..e5aa2b53b 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -1,5 +1,6 @@ """OS support on supervisor.""" from collections.abc import Awaitable +import errno import logging from pathlib import Path @@ -13,6 +14,7 @@ from ..dbus.rauc import RaucState from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError from ..jobs.const import JobCondition, JobExecutionLimit from ..jobs.decorator import Job +from ..resolution.const import UnhealthyReason from .data_disk import DataDisk _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -120,6 +122,8 @@ class OSManager(CoreSysAttributes): ) from err except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE raise HassOSUpdateError( f"Can't write OTA file: {err!s}", _LOGGER.error ) from err diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 558d5fa93..2c57f4c4e 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -4,6 +4,7 @@ Code: https://github.com/home-assistant/plugin-audio """ import asyncio from contextlib import suppress +import errno import logging from pathlib import Path, PurePath import shutil @@ -25,6 +26,7 @@ from ..exceptions import ( ) from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job +from ..resolution.const import UnhealthyReason from ..utils.json import write_json_file from ..utils.sentry import capture_exception from .base import PluginBase @@ -83,6 +85,9 @@ class PluginAudio(PluginBase): PULSE_CLIENT_TMPL.read_text(encoding="utf-8") ) 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) await super().load() @@ -93,6 +98,8 @@ class PluginAudio(PluginBase): try: shutil.copy(ASOUND_TMPL, asound) 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) async def install(self) -> None: diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index b6c320e7d..393a055ba 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -4,6 +4,7 @@ Code: https://github.com/home-assistant/plugin-dns """ import asyncio from contextlib import suppress +import errno from ipaddress import IPv4Address import logging from pathlib import Path @@ -29,7 +30,7 @@ from ..exceptions import ( ) from ..jobs.const import JobExecutionLimit 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.sentry import capture_exception from ..validate import dns_url @@ -146,12 +147,16 @@ class PluginDns(PluginBase): RESOLV_TMPL.read_text(encoding="utf-8") ) 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) try: self.hosts_template = jinja2.Template( HOSTS_TMPL.read_text(encoding="utf-8") ) 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) await self._init_hosts() @@ -364,6 +369,8 @@ class PluginDns(PluginBase): self.hosts.write_text, data, encoding="utf-8" ) 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 async def add_host( @@ -436,6 +443,12 @@ class PluginDns(PluginBase): def _write_resolv(self, resolv_conf: Path) -> None: """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"] # Read resolv config @@ -445,6 +458,8 @@ class PluginDns(PluginBase): try: resolv_conf.write_text(data) 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) return diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 232935877..1a06914c7 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -59,9 +59,10 @@ class UnhealthyReason(StrEnum): """Reasons for unsupported status.""" DOCKER = "docker" + OSERROR_BAD_MESSAGE = "oserror_bad_message" + PRIVILEGED = "privileged" SUPERVISOR = "supervisor" SETUP = "setup" - PRIVILEGED = "privileged" UNTRUSTED = "untrusted" diff --git a/supervisor/resolution/evaluations/source_mods.py b/supervisor/resolution/evaluations/source_mods.py index fd13e5493..b81848955 100644 --- a/supervisor/resolution/evaluations/source_mods.py +++ b/supervisor/resolution/evaluations/source_mods.py @@ -1,4 +1,5 @@ """Evaluation class for Content Trust.""" +import errno import logging from pathlib import Path @@ -6,7 +7,7 @@ from ...const import CoreState from ...coresys import CoreSys from ...exceptions import CodeNotaryError, CodeNotaryUntrusted 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 _SUPERVISOR_SOURCE = Path("/usr/src/supervisor/supervisor") @@ -48,6 +49,9 @@ class EvaluateSourceMods(EvaluateBase): calc_checksum_path_sourcecode, _SUPERVISOR_SOURCE ) except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.create_issue( IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM ) diff --git a/supervisor/store/data.py b/supervisor/store/data.py index 82c0ada90..76253b639 100644 --- a/supervisor/store/data.py +++ b/supervisor/store/data.py @@ -1,5 +1,6 @@ """Init file for Supervisor add-on data.""" from dataclasses import dataclass +import errno import logging from pathlib import Path from typing import Any @@ -19,7 +20,7 @@ from ..const import ( ) from ..coresys import CoreSys, CoreSysAttributes 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.json import read_json_file from .const import StoreType @@ -157,7 +158,9 @@ class StoreData(CoreSysAttributes): addon_list = await self.sys_run_in_executor(_get_addons_list) except OSError as err: 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] self.sys_resolution.create_issue( IssueType.CORRUPT_REPOSITORY, diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index 34ed9df97..ea6708dc4 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable from contextlib import suppress from datetime import timedelta +import errno from ipaddress import IPv4Address import logging from pathlib import Path @@ -27,7 +28,7 @@ from .exceptions import ( ) from .jobs.const import JobCondition, JobExecutionLimit 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.sentry import capture_exception @@ -155,6 +156,8 @@ class Supervisor(CoreSysAttributes): try: profile_file.write_text(data, encoding="utf-8") except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE raise SupervisorAppArmorError( f"Can't write temporary profile: {err!s}", _LOGGER.error ) from err diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 496eb5fd0..d205c890c 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta +import errno from pathlib import Path from unittest.mock import MagicMock, PropertyMock, patch @@ -696,3 +697,27 @@ async def test_local_example_ingress_port_set( await install_addon_example.load() 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 diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 4ccd73e45..eed14badc 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1,6 +1,8 @@ """Test BackupManager class.""" import asyncio +import errno +from pathlib import Path from shutil import rmtree 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_wal) == {"hello": "world"} 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 diff --git a/tests/hardware/test_helper.py b/tests/hardware/test_helper.py index c5c2359de..39907adf9 100644 --- a/tests/hardware/test_helper.py +++ b/tests/hardware/test_helper.py @@ -1,12 +1,15 @@ """Test hardware utils.""" -# pylint: disable=protected-access +import errno 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 -def test_have_audio(coresys): +def test_have_audio(coresys: CoreSys): """Test usb device filter.""" assert not coresys.hardware.helper.support_audio @@ -26,7 +29,7 @@ def test_have_audio(coresys): assert coresys.hardware.helper.support_audio -def test_have_usb(coresys): +def test_have_usb(coresys: CoreSys): """Test usb device filter.""" assert not coresys.hardware.helper.support_usb @@ -46,7 +49,7 @@ def test_have_usb(coresys): assert coresys.hardware.helper.support_usb -def test_have_gpio(coresys): +def test_have_gpio(coresys: CoreSys): """Test usb device filter.""" assert not coresys.hardware.helper.support_gpio @@ -66,7 +69,7 @@ def test_have_gpio(coresys): assert coresys.hardware.helper.support_gpio -def test_hide_virtual_device(coresys): +def test_hide_virtual_device(coresys: CoreSys): """Test hidding virtual devices.""" udev_device = MagicMock() @@ -81,3 +84,15 @@ def test_hide_virtual_device(coresys): udev_device.sys_path = "/sys/devices/virtual/vc/vcs1" 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 diff --git a/tests/homeassistant/test_module.py b/tests/homeassistant/test_module.py index a26234ffd..e97de0620 100644 --- a/tests/homeassistant/test_module.py +++ b/tests/homeassistant/test_module.py @@ -1,9 +1,12 @@ """Test Homeassistant module.""" import asyncio +import errno from pathlib import Path from unittest.mock import AsyncMock, patch +from pytest import LogCaptureFixture + from supervisor.const import CoreState from supervisor.coresys import CoreSys 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__( 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 diff --git a/tests/host/test_apparmor_control.py b/tests/host/test_apparmor_control.py new file mode 100644 index 000000000..41da7a898 --- /dev/null +++ b/tests/host/test_apparmor_control.py @@ -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 diff --git a/tests/plugins/test_audio.py b/tests/plugins/test_audio.py index 29150feed..448d9b198 100644 --- a/tests/plugins/test_audio.py +++ b/tests/plugins/test_audio.py @@ -1,4 +1,5 @@ """Test audio plugin.""" +import errno from pathlib import Path from unittest.mock import AsyncMock, Mock, patch @@ -56,3 +57,26 @@ async def test_config_write( "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 diff --git a/tests/plugins/test_dns.py b/tests/plugins/test_dns.py index 6d4fcbf59..a112b54ec 100644 --- a/tests/plugins/test_dns.py +++ b/tests/plugins/test_dns.py @@ -1,5 +1,6 @@ """Test DNS plugin.""" import asyncio +import errno from ipaddress import IPv4Address from pathlib import Path 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") ] 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 diff --git a/tests/resolution/evaluation/test_evaluate_apparmor.py b/tests/resolution/evaluation/test_evaluate_apparmor.py index c0d961767..01d87ff19 100644 --- a/tests/resolution/evaluation/test_evaluate_apparmor.py +++ b/tests/resolution/evaluation/test_evaluate_apparmor.py @@ -1,5 +1,6 @@ """Test evaluation base.""" # pylint: disable=import-error,protected-access +import errno from unittest.mock import patch from supervisor.const import CoreState @@ -50,3 +51,20 @@ async def test_did_run(coresys: CoreSys): await apparmor() evaluate.assert_not_called() 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 diff --git a/tests/resolution/evaluation/test_evaluate_source_mods.py b/tests/resolution/evaluation/test_evaluate_source_mods.py index bb2a8cf74..1e7f0223d 100644 --- a/tests/resolution/evaluation/test_evaluate_source_mods.py +++ b/tests/resolution/evaluation/test_evaluate_source_mods.py @@ -1,5 +1,6 @@ """Test evaluation base.""" # pylint: disable=import-error,protected-access +import errno import os from pathlib import Path from unittest.mock import AsyncMock, patch @@ -7,6 +8,8 @@ from unittest.mock import AsyncMock, patch from supervisor.const import CoreState from supervisor.coresys import CoreSys 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 @@ -56,3 +59,30 @@ async def test_did_run(coresys: CoreSys): await sourcemods() evaluate.assert_not_called() 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 diff --git a/tests/store/test_reading_addons.py b/tests/store/test_reading_addons.py index 790db8aed..aba5f8b0b 100644 --- a/tests/store/test_reading_addons.py +++ b/tests/store/test_reading_addons.py @@ -1,8 +1,13 @@ """Test that we are reading add-on files correctly.""" +import errno from pathlib import Path from unittest.mock import patch 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): @@ -23,3 +28,23 @@ async def test_read_addon_files(coresys: CoreSys): assert len(addon_list) == 1 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 diff --git a/tests/test_core.py b/tests/test_core.py index 08a044b6d..80800d0f8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,8 +1,11 @@ """Testing handling with CoreState.""" # pylint: disable=W0212 import datetime +import errno from unittest.mock import AsyncMock, PropertyMock, patch +from pytest import LogCaptureFixture + from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.exceptions import WhoamiSSLError @@ -14,7 +17,6 @@ from supervisor.utils.whoami import WhoamiData def test_write_state(run_dir, coresys: CoreSys): """Test write corestate to /run/supervisor.""" - coresys.core.state = 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_set_datetime.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 diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index c9a516a85..2da48effe 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -1,6 +1,7 @@ """Test supervisor object.""" from datetime import timedelta +import errno from unittest.mock import AsyncMock, Mock, PropertyMock, patch from aiohttp import ClientTimeout @@ -11,7 +12,11 @@ import pytest from supervisor.const import UpdateChannel from supervisor.coresys import CoreSys 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.resolution.const import ContextType, IssueType from supervisor.resolution.data import Issue @@ -108,3 +113,22 @@ async def test_update_apparmor( timeout=ClientTimeout(total=10), ) 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