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 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
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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
)

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

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."""
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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