From 2040102e2190350dfb17ba8d8047f400e2f5135a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 14 Nov 2020 16:16:00 +0100 Subject: [PATCH] Handle Unhealthy like Unsupported (#2255) * Handle Unhealthy like Unsupported * Add tests * Add unhealthy to sentry * Add test --- supervisor/addons/__init__.py | 8 +++++++- supervisor/api/resolution.py | 3 ++- supervisor/const.py | 1 + supervisor/core.py | 14 +++++++++----- supervisor/misc/filter.py | 1 + supervisor/misc/hwmon.py | 3 ++- supervisor/resolution/__init__.py | 13 +++++++++++++ supervisor/resolution/const.py | 9 +++++++++ supervisor/resolution/evaluate.py | 4 ++-- supervisor/store/data.py | 5 ++++- tests/api/test_resolution.py | 18 +++++++++++++++++- tests/job/test_job_decorator.py | 3 ++- tests/misc/test_filter_data.py | 21 ++++++++++++++++++++- tests/resolution/test_resolution_manager.py | 14 ++++++++++++-- 14 files changed, 101 insertions(+), 16 deletions(-) diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 3fadf34e8..4480e48bf 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -18,6 +18,7 @@ from ..exceptions import ( HomeAssistantAPIError, HostAppArmorError, ) +from ..resolution.const import ContextType, IssueType, SuggestionType from ..store.addon import AddonStore from ..utils import check_exception_chain from .addon import Addon @@ -376,7 +377,12 @@ class AddonManager(CoreSysAttributes): continue except DockerError as err: _LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err) - self.sys_core.healthy = False + self.sys_resolution.create_issue( + IssueType.CORRUPT_DOCKER, + ContextType.ADDON, + reference=addon.slug, + suggestions=[SuggestionType.EXECUTE_REPAIR], + ) self.sys_capture_exception(err) else: self.sys_plugins.dns.add_host( diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index bf933f4fc..d34d752ed 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -4,7 +4,7 @@ from typing import Any, Dict from aiohttp import web import attr -from ..const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNSUPPORTED +from ..const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNHEALTHY, ATTR_UNSUPPORTED from ..coresys import CoreSysAttributes from ..exceptions import APIError, ResolutionNotFound from .utils import api_process @@ -18,6 +18,7 @@ class APIResoulution(CoreSysAttributes): """Return network information.""" return { ATTR_UNSUPPORTED: self.sys_resolution.unsupported, + ATTR_UNHEALTHY: self.sys_resolution.unhealthy, ATTR_SUGGESTIONS: [ attr.asdict(suggestion) for suggestion in self.sys_resolution.suggestions diff --git a/supervisor/const.py b/supervisor/const.py index 678d3f262..d4e384c47 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -289,6 +289,7 @@ ATTR_SIGNAL = "signal" ATTR_MAC = "mac" ATTR_FREQUENCY = "frequency" ATTR_ACCESSPOINTS = "accesspoints" +ATTR_UNHEALTHY = "unhealthy" PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" diff --git a/supervisor/core.py b/supervisor/core.py index 124b8e3fd..c65bca228 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -14,7 +14,7 @@ from .exceptions import ( HomeAssistantError, SupervisorUpdateError, ) -from .resolution.const import ContextType, IssueType +from .resolution.const import ContextType, IssueType, UnhealthyReason _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -25,7 +25,6 @@ class Core(CoreSysAttributes): def __init__(self, coresys: CoreSys): """Initialize Supervisor object.""" self.coresys: CoreSys = coresys - self.healthy: bool = True self._state: Optional[CoreState] = None @property @@ -34,10 +33,15 @@ class Core(CoreSysAttributes): return self._state @property - def supported(self) -> CoreState: + def supported(self) -> bool: """Return true if the installation is supported.""" return len(self.sys_resolution.unsupported) == 0 + @property + def healthy(self) -> bool: + """Return true if the installation is healthy.""" + return len(self.sys_resolution.unhealthy) == 0 + @state.setter def state(self, new_state: CoreState) -> None: """Set core into new state.""" @@ -67,7 +71,7 @@ class Core(CoreSysAttributes): self.sys_resolution.create_issue( IssueType.UPDATE_ROLLBACK, ContextType.SUPERVISOR ) - self.healthy = False + self.sys_resolution.unhealthy = UnhealthyReason.SUPERVISOR _LOGGER.error( "Update '%s' of Supervisor '%s' failed!", self.sys_config.version, @@ -119,7 +123,7 @@ class Core(CoreSysAttributes): _LOGGER.critical( "Fatal error happening on load Task %s: %s", setup_task, err ) - self.healthy = False + self.sys_resolution.unhealthy = UnhealthyReason.SETUP self.sys_capture_exception(err) # Evaluate the system diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index f4a7093cf..d3f5dde16 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -75,6 +75,7 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: "supervisor": coresys.supervisor.version, }, "issues": [attr.asdict(issue) for issue in coresys.resolution.issues], + "unhealthy": coresys.resolution.unhealthy, } ) event.setdefault("tags", []).extend( diff --git a/supervisor/misc/hwmon.py b/supervisor/misc/hwmon.py index 58180988a..ebb133962 100644 --- a/supervisor/misc/hwmon.py +++ b/supervisor/misc/hwmon.py @@ -7,6 +7,7 @@ from typing import Optional import pyudev from ..coresys import CoreSys, CoreSysAttributes +from ..resolution.const import UnhealthyReason from ..utils import AsyncCallFilter _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -28,7 +29,7 @@ class HwMonitor(CoreSysAttributes): self.monitor = pyudev.Monitor.from_netlink(self.context) self.observer = pyudev.MonitorObserver(self.monitor, self._udev_events) except OSError: - self.sys_core.healthy = False + self.sys_resolution.unhealthy = UnhealthyReason.PRIVILEGED _LOGGER.critical("Not privileged to run udev monitor!") else: self.observer.start() diff --git a/supervisor/resolution/__init__.py b/supervisor/resolution/__init__.py index 230867a1c..b957675b6 100644 --- a/supervisor/resolution/__init__.py +++ b/supervisor/resolution/__init__.py @@ -9,6 +9,7 @@ from .const import ( ContextType, IssueType, SuggestionType, + UnhealthyReason, UnsupportedReason, ) from .data import Issue, Suggestion @@ -32,6 +33,7 @@ class ResolutionManager(CoreSysAttributes): self._suggestions: List[Suggestion] = [] self._issues: List[Issue] = [] self._unsupported: List[UnsupportedReason] = [] + self._unhealthy: List[UnhealthyReason] = [] @property def evaluate(self) -> ResolutionEvaluation: @@ -81,6 +83,17 @@ class ResolutionManager(CoreSysAttributes): if reason not in self._unsupported: self._unsupported.append(reason) + @property + def unhealthy(self) -> List[UnhealthyReason]: + """Return a list of unsupported reasons.""" + return self._unhealthy + + @unhealthy.setter + def unhealthy(self, reason: UnhealthyReason) -> None: + """Add a reason for unsupported.""" + if reason not in self._unhealthy: + self._unhealthy.append(reason) + def get_suggestion(self, uuid: str) -> Suggestion: """Return suggestion with uuid.""" for suggestion in self._suggestions: diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 334e4434b..48bcddccc 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -33,6 +33,15 @@ class UnsupportedReason(str, Enum): SYSTEMD = "systemd" +class UnhealthyReason(str, Enum): + """Reasons for unsupported status.""" + + DOCKER = "docker" + SUPERVISOR = "supervisor" + SETUP = "setup" + PRIVILEGED = "privileged" + + class IssueType(str, Enum): """Issue type.""" diff --git a/supervisor/resolution/evaluate.py b/supervisor/resolution/evaluate.py index 41a74cc4e..92fd4cd35 100644 --- a/supervisor/resolution/evaluate.py +++ b/supervisor/resolution/evaluate.py @@ -2,7 +2,7 @@ import logging from ..coresys import CoreSys, CoreSysAttributes -from .const import UnsupportedReason +from .const import UnhealthyReason, UnsupportedReason from .evaluations.container import EvaluateContainer from .evaluations.dbus import EvaluateDbus from .evaluations.docker_configuration import EvaluateDockerConfiguration @@ -54,6 +54,6 @@ class ResolutionEvaluation(CoreSysAttributes): await self._systemd() if any(reason in self.sys_resolution.unsupported for reason in UNHEALTHY): - self.sys_core.healthy = False + self.sys_resolution.unhealthy = UnhealthyReason.DOCKER _LOGGER.info("System evaluation complete") diff --git a/supervisor/store/data.py b/supervisor/store/data.py index 095ed85d6..b810493bb 100644 --- a/supervisor/store/data.py +++ b/supervisor/store/data.py @@ -16,6 +16,7 @@ from ..const import ( ) from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import JsonFileError +from ..resolution.const import ContextType, IssueType from ..utils.json import read_json_file from .utils import extract_hash_from_path from .validate import SCHEMA_REPOSITORY_CONFIG @@ -82,7 +83,9 @@ class StoreData(CoreSysAttributes): if ".git" not in addon.parts ] except OSError as err: - self.sys_core.healthy = False + self.sys_resolution.create_issue( + IssueType.CORRUPT_REPOSITORY, ContextType.SYSTEM + ) _LOGGER.critical( "Can't process %s because of Filesystem issues: %s", repository, err ) diff --git a/tests/api/test_resolution.py b/tests/api/test_resolution.py index 78daa419c..d392e9fcc 100644 --- a/tests/api/test_resolution.py +++ b/tests/api/test_resolution.py @@ -3,13 +3,19 @@ from unittest.mock import AsyncMock import pytest -from supervisor.const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNSUPPORTED +from supervisor.const import ( + ATTR_ISSUES, + ATTR_SUGGESTIONS, + ATTR_UNHEALTHY, + ATTR_UNSUPPORTED, +) from supervisor.coresys import CoreSys from supervisor.exceptions import ResolutionError from supervisor.resolution.const import ( ContextType, IssueType, SuggestionType, + UnhealthyReason, UnsupportedReason, ) from supervisor.resolution.data import Issue, Suggestion @@ -84,3 +90,13 @@ async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client): assert IssueType.UPDATE_FAILED == coresys.resolution.issues[-1].type await api_client.delete(f"/resolution/issue/{updated_failed.uuid}") assert updated_failed not in coresys.resolution.issues + + +@pytest.mark.asyncio +async def test_api_resolution_unhealthy(coresys: CoreSys, api_client): + """Test resolution manager api.""" + coresys.resolution.unhealthy = UnhealthyReason.DOCKER + + resp = await api_client.get("/resolution/info") + result = await resp.json() + assert UnhealthyReason.DOCKER == result["data"][ATTR_UNHEALTHY][-1] diff --git a/tests/job/test_job_decorator.py b/tests/job/test_job_decorator.py index b1e20f964..b1b60c34f 100644 --- a/tests/job/test_job_decorator.py +++ b/tests/job/test_job_decorator.py @@ -5,6 +5,7 @@ from unittest.mock import patch from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.jobs.decorator import Job, JobCondition +from supervisor.resolution.const import UnhealthyReason async def test_healthy(coresys: CoreSys): @@ -25,7 +26,7 @@ async def test_healthy(coresys: CoreSys): test = TestClass(coresys) assert await test.execute() - coresys.core.healthy = False + coresys.resolution.unhealthy = UnhealthyReason.DOCKER assert not await test.execute() diff --git a/tests/misc/test_filter_data.py b/tests/misc/test_filter_data.py index 1b8491b71..70b3ffc4d 100644 --- a/tests/misc/test_filter_data.py +++ b/tests/misc/test_filter_data.py @@ -7,7 +7,12 @@ import pytest from supervisor.const import SUPERVISOR_VERSION, CoreState from supervisor.exceptions import AddonConfigurationError from supervisor.misc.filter import filter_data -from supervisor.resolution.const import ContextType, IssueType, UnsupportedReason +from supervisor.resolution.const import ( + ContextType, + IssueType, + UnhealthyReason, + UnsupportedReason, +) SAMPLE_EVENT = {"sample": "event", "extra": {"Test": "123"}} @@ -117,3 +122,17 @@ def test_issues_on_report(coresys): assert "issues" in event["contexts"] assert event["contexts"]["issues"][0]["type"] == IssueType.FATAL_ERROR assert event["contexts"]["issues"][0]["context"] == ContextType.SYSTEM + + +def test_unhealthy_on_report(coresys): + """Attach unhealthy to report.""" + + coresys.config.diagnostics = True + coresys.core.state = CoreState.RUNNING + coresys.resolution.unhealthy = UnhealthyReason.DOCKER + + with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0 ** 3))): + event = filter_data(coresys, SAMPLE_EVENT, {}) + + assert "issues" in event["contexts"] + assert event["contexts"]["unhealthy"][-1] == UnhealthyReason.DOCKER diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 542ad4465..d48a01c0c 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -17,6 +17,7 @@ from supervisor.resolution.const import ( ContextType, IssueType, SuggestionType, + UnhealthyReason, UnsupportedReason, ) from supervisor.resolution.data import Issue, Suggestion @@ -25,8 +26,8 @@ from supervisor.utils.dt import utcnow from supervisor.utils.tar import SecureTarFile -def test_properies(coresys: CoreSys): - """Test resolution manager properties.""" +def test_properies_unsupported(coresys: CoreSys): + """Test resolution manager properties unsupported.""" assert coresys.core.supported @@ -34,6 +35,15 @@ def test_properies(coresys: CoreSys): assert not coresys.core.supported +def test_properies_unhealthy(coresys: CoreSys): + """Test resolution manager properties unhealthy.""" + + assert coresys.core.healthy + + coresys.resolution.unhealthy = UnhealthyReason.SUPERVISOR + assert not coresys.core.healthy + + async def test_clear_snapshots(coresys: CoreSys, tmp_path): """Test snapshot cleanup.""" for slug in ["sn1", "sn2", "sn3", "sn4", "sn5"]: