diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 6efb4a52f..357acf647 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -23,6 +23,7 @@ from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from ..store.addon import AddonStore from ..utils import check_exception_chain +from ..utils.sentry import capture_exception from .addon import Addon from .const import ADDON_UPDATE_CONDITIONS from .data import AddonsData @@ -114,7 +115,7 @@ class AddonManager(CoreSysAttributes): addon.boot = AddonBoot.MANUAL addon.save_persist() except Exception as err: # pylint: disable=broad-except - self.sys_capture_exception(err) + capture_exception(err) else: continue @@ -142,7 +143,7 @@ class AddonManager(CoreSysAttributes): await addon.stop() except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) - self.sys_capture_exception(err) + capture_exception(err) @Job( conditions=ADDON_UPDATE_CONDITIONS, @@ -421,7 +422,7 @@ class AddonManager(CoreSysAttributes): reference=addon.slug, suggestions=[SuggestionType.EXECUTE_REPAIR], ) - self.sys_capture_exception(err) + capture_exception(err) else: self.sys_plugins.dns.add_host( ipv4=addon.ip_address, names=[addon.hostname], write=False diff --git a/supervisor/api/const.py b/supervisor/api/const.py index c2efcfd26..6a444261c 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -9,9 +9,6 @@ CONTENT_TYPE_URL = "application/x-www-form-urlencoded" COOKIE_INGRESS = "ingress_session" -HEADER_TOKEN_OLD = "X-Hassio-Key" -HEADER_TOKEN = "X-Supervisor-Token" - ATTR_APPARMOR_VERSION = "apparmor_version" ATTR_AGENT_VERSION = "agent_version" ATTR_AVAILABLE_UPDATES = "available_updates" diff --git a/supervisor/api/ingress.py b/supervisor/api/ingress.py index 74df1a8ac..2e13b2b82 100644 --- a/supervisor/api/ingress.py +++ b/supervisor/api/ingress.py @@ -22,9 +22,11 @@ from ..const import ( ATTR_PANELS, ATTR_SESSION, ATTR_TITLE, + HEADER_TOKEN, + HEADER_TOKEN_OLD, ) from ..coresys import CoreSysAttributes -from .const import COOKIE_INGRESS, HEADER_TOKEN, HEADER_TOKEN_OLD +from .const import COOKIE_INGRESS from .utils import api_process, api_validate, require_home_assistant _LOGGER: logging.Logger = logging.getLogger(__name__) diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 64592b4bd..7d3a3e45c 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -46,6 +46,7 @@ from ..const import ( from ..coresys import CoreSysAttributes from ..exceptions import APIError from ..store.validate import repositories +from ..utils.sentry import close_sentry, init_sentry from ..utils.validate import validate_timezone from ..validate import version_tag, wait_boot from .const import CONTENT_TYPE_BINARY @@ -144,6 +145,11 @@ class APISupervisor(CoreSysAttributes): self.sys_config.diagnostics = body[ATTR_DIAGNOSTICS] self.sys_dbus.agent.diagnostics = body[ATTR_DIAGNOSTICS] + if body[ATTR_DIAGNOSTICS]: + init_sentry(self.coresys) + else: + close_sentry() + if ATTR_LOGGING in body: self.sys_config.logging = body[ATTR_LOGGING] diff --git a/supervisor/api/utils.py b/supervisor/api/utils.py index ccd23f6bc..aa94fc402 100644 --- a/supervisor/api/utils.py +++ b/supervisor/api/utils.py @@ -10,6 +10,8 @@ import voluptuous as vol from voluptuous.humanize import humanize_error from ..const import ( + HEADER_TOKEN, + HEADER_TOKEN_OLD, JSON_DATA, JSON_MESSAGE, JSON_RESULT, @@ -22,7 +24,7 @@ from ..exceptions import APIError, APIForbidden, DockerAPIError, HassioError from ..utils import check_exception_chain, get_message_from_exception_chain from ..utils.json import JSONEncoder from ..utils.log_format import format_message -from .const import CONTENT_TYPE_BINARY, HEADER_TOKEN, HEADER_TOKEN_OLD +from .const import CONTENT_TYPE_BINARY def excract_supervisor_token(request: web.Request) -> str | None: diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index 5c82b2f78..73d3fb810 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -17,6 +17,7 @@ from ..exceptions import AddonsError from ..jobs.decorator import Job, JobCondition from ..utils.common import FileConfiguration from ..utils.dt import utcnow +from ..utils.sentry import capture_exception from .backup import Backup from .const import BackupType from .utils import create_slug @@ -172,7 +173,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes): except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Backup %s error", backup.slug) - self.sys_capture_exception(err) + capture_exception(err) return None else: self._backups[backup.slug] = backup @@ -296,7 +297,7 @@ class BackupManager(FileConfiguration, CoreSysAttributes): except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Restore %s error", backup.slug) - self.sys_capture_exception(err) + capture_exception(err) return False else: return True diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 8fc02aa9e..67389ab35 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -5,13 +5,6 @@ from pathlib import Path import signal from colorlog import ColoredFormatter -import sentry_sdk -from sentry_sdk.integrations.aiohttp import AioHttpIntegration -from sentry_sdk.integrations.atexit import AtexitIntegration -from sentry_sdk.integrations.dedupe import DedupeIntegration -from sentry_sdk.integrations.excepthook import ExcepthookIntegration -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.integrations.threading import ThreadingIntegration from .addons import AddonManager from .api import RestAPI @@ -28,7 +21,6 @@ from .const import ( ENV_SUPERVISOR_SHARE, MACHINE_ID, SOCKET_DOCKER, - SUPERVISOR_VERSION, LogLevel, UpdateChannel, ) @@ -42,7 +34,6 @@ from .homeassistant.module import HomeAssistant from .host.manager import HostManager from .ingress import Ingress from .jobs import JobManager -from .misc.filter import filter_data from .misc.scheduler import Scheduler from .misc.tasks import Tasks from .os.manager import OSManager @@ -54,6 +45,7 @@ from .store import StoreManager from .store.validate import ensure_builtin_repositories from .supervisor import Supervisor from .updater import Updater +from .utils.sentry import init_sentry _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -90,7 +82,8 @@ async def initialize_coresys() -> CoreSys: coresys.bus = Bus(coresys) # diagnostics - setup_diagnostics(coresys) + if coresys.config.diagnostics: + init_sentry(coresys) # bootstrap config initialize_system(coresys) @@ -316,25 +309,3 @@ def supervisor_debugger(coresys: CoreSys) -> None: if coresys.config.debug_block: _LOGGER.info("Wait until debugger is attached") debugpy.wait_for_client() - - -def setup_diagnostics(coresys: CoreSys) -> None: - """Sentry diagnostic backend.""" - _LOGGER.info("Initializing Supervisor Sentry") - # pylint: disable=abstract-class-instantiated - sentry_sdk.init( - dsn="https://9c6ea70f49234442b4746e447b24747e@o427061.ingest.sentry.io/5370612", - before_send=lambda event, hint: filter_data(coresys, event, hint), - auto_enabling_integrations=False, - default_integrations=False, - integrations=[ - AioHttpIntegration(), - ExcepthookIntegration(), - DedupeIntegration(), - AtexitIntegration(), - ThreadingIntegration(), - LoggingIntegration(level=logging.WARNING, event_level=logging.CRITICAL), - ], - release=SUPERVISOR_VERSION, - max_breadcrumbs=30, - ) diff --git a/supervisor/const.py b/supervisor/const.py index a515f8495..346108707 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -69,6 +69,9 @@ JSON_RESULT = "result" RESULT_ERROR = "error" RESULT_OK = "ok" +HEADER_TOKEN_OLD = "X-Hassio-Key" +HEADER_TOKEN = "X-Supervisor-Token" + ENV_HOMEASSISTANT_REPOSITORY = "HOMEASSISTANT_REPOSITORY" ENV_SUPERVISOR_DEV = "SUPERVISOR_DEV" ENV_SUPERVISOR_MACHINE = "SUPERVISOR_MACHINE" diff --git a/supervisor/core.py b/supervisor/core.py index 8f56f6b2f..2b211e405 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -20,6 +20,7 @@ from .exceptions import ( from .homeassistant.core import LANDINGPAGE from .resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason from .utils.dt import utcnow +from .utils.sentry import capture_exception from .utils.whoami import retrieve_whoami _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -153,7 +154,7 @@ class Core(CoreSysAttributes): "Fatal error happening on load Task %s: %s", setup_task, err ) self.sys_resolution.unhealthy = UnhealthyReason.SETUP - self.sys_capture_exception(err) + capture_exception(err) # Set OS Agent diagnostics if needed if ( @@ -196,7 +197,7 @@ class Core(CoreSysAttributes): "future versions of Home Assistant!" ) self.sys_resolution.unhealthy = UnhealthyReason.SUPERVISOR - self.sys_capture_exception(err) + capture_exception(err) # Start addon mark as initialize await self.sys_addons.boot(AddonStartup.INITIALIZE) @@ -226,12 +227,12 @@ class Core(CoreSysAttributes): await self.sys_homeassistant.core.start() except HomeAssistantCrashError as err: _LOGGER.error("Can't start Home Assistant Core - rebuiling") - self.sys_capture_exception(err) + capture_exception(err) with suppress(HomeAssistantError): await self.sys_homeassistant.core.rebuild() except HomeAssistantError as err: - self.sys_capture_exception(err) + capture_exception(err) else: _LOGGER.info("Skiping start of Home Assistant") diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 4456825e2..67e0efe06 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -10,7 +10,6 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar import aiohttp -import sentry_sdk from .config import CoreConfig from .const import ENV_SUPERVISOR_DEV, SERVER_SOFTWARE @@ -514,10 +513,6 @@ class CoreSys: """Create an async task.""" return self.loop.create_task(coroutine) - def capture_exception(self, err: Exception) -> None: - """Capture a exception.""" - sentry_sdk.capture_exception(err) - class CoreSysAttributes: """Inherit basic CoreSysAttributes.""" @@ -692,7 +687,3 @@ class CoreSysAttributes: def sys_create_task(self, coroutine: Coroutine) -> asyncio.Task: """Create an async task.""" return self.coresys.create_task(coroutine) - - def sys_capture_exception(self, err: Exception) -> None: - """Capture a exception.""" - self.coresys.capture_exception(err) diff --git a/supervisor/dbus/network/__init__.py b/supervisor/dbus/network/__init__.py index eadbcf4bb..cf6310858 100644 --- a/supervisor/dbus/network/__init__.py +++ b/supervisor/dbus/network/__init__.py @@ -4,7 +4,6 @@ from typing import Any from awesomeversion import AwesomeVersion, AwesomeVersionException from dbus_fast.aio.message_bus import MessageBus -import sentry_sdk from ...exceptions import ( DBusError, @@ -12,6 +11,7 @@ from ...exceptions import ( DBusInterfaceError, HostNotSupportedError, ) +from ...utils.sentry import capture_exception from ..const import ( DBUS_ATTR_CONNECTION_ENABLED, DBUS_ATTR_DEVICES, @@ -194,7 +194,7 @@ class NetworkManager(DBusInterfaceProxy): continue except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error while processing %s: %s", device, err) - sentry_sdk.capture_exception(err) + capture_exception(err) continue # Skeep interface diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 7070ffe93..00a4fc087 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -44,6 +44,7 @@ from ..hardware.data import Device from ..jobs.decorator import Job, JobCondition, JobExecutionLimit from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils import process_lock +from ..utils.sentry import capture_exception from .const import ( DBUS_PATH, DBUS_VOLUME, @@ -516,7 +517,7 @@ class DockerAddon(DockerInterface): ) except CoreDNSError as err: _LOGGER.warning("Can't update DNS for %s", self.name) - self.sys_capture_exception(err) + capture_exception(err) # Hardware Access if self.addon.static_devices: @@ -699,7 +700,7 @@ class DockerAddon(DockerInterface): self.sys_plugins.dns.delete_host(self.addon.hostname) except CoreDNSError as err: _LOGGER.warning("Can't update DNS for %s", self.name) - self.sys_capture_exception(err) + capture_exception(err) # Hardware if self._hw_listener: diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 6d48b1583..b75edd11f 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -36,6 +36,7 @@ from ..exceptions import ( ) from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils import process_lock +from ..utils.sentry import capture_exception from .const import ContainerState, RestartPolicy from .manager import CommandReturn from .monitor import DockerContainerStateEvent @@ -259,7 +260,7 @@ class DockerInterface(CoreSysAttributes): f"Can't install {image}:{version!s}: {err}", _LOGGER.error ) from err except (docker.errors.DockerException, requests.RequestException) as err: - self.sys_capture_exception(err) + capture_exception(err) raise DockerError( f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error ) from err diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index 1eb7d3b8f..2905820cb 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -27,6 +27,7 @@ from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType from ..utils import convert_to_ascii, process_lock +from ..utils.sentry import capture_exception from .const import ( LANDINGPAGE, WATCHDOG_MAX_ATTEMPTS, @@ -125,16 +126,18 @@ class HomeAssistantCore(CoreSysAttributes): await self.instance.install( LANDINGPAGE, image=self.sys_updater.image_homeassistant ) - except DockerError: - _LOGGER.warning("Fails install landingpage, retry after 30sec") - await asyncio.sleep(30) - except Exception as err: # pylint: disable=broad-except - self.sys_capture_exception(err) - else: - self.sys_homeassistant.version = LANDINGPAGE - self.sys_homeassistant.image = self.sys_updater.image_homeassistant - self.sys_homeassistant.save_data() break + except DockerError: + pass + except Exception as err: # pylint: disable=broad-except + capture_exception(err) + + _LOGGER.warning("Fails install landingpage, retry after 30sec") + await asyncio.sleep(30) + + self.sys_homeassistant.version = LANDINGPAGE + self.sys_homeassistant.image = self.sys_updater.image_homeassistant + self.sys_homeassistant.save_data() @process_lock async def install(self) -> None: @@ -155,7 +158,7 @@ class HomeAssistantCore(CoreSysAttributes): except DockerError: pass except Exception as err: # pylint: disable=broad-except - self.sys_capture_exception(err) + capture_exception(err) _LOGGER.warning("Error on Home Assistant installation. Retry in 30sec") await asyncio.sleep(30) @@ -473,7 +476,7 @@ class HomeAssistantCore(CoreSysAttributes): try: await self.start() except HomeAssistantError as err: - self.sys_capture_exception(err) + capture_exception(err) else: break @@ -485,7 +488,7 @@ class HomeAssistantCore(CoreSysAttributes): except HomeAssistantError as err: attempts = attempts + 1 _LOGGER.error("Watchdog restart of Home Assistant failed!") - self.sys_capture_exception(err) + capture_exception(err) else: break diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index ac7bf38ca..3acb73c44 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -6,13 +6,12 @@ from functools import wraps import logging from typing import Any -import sentry_sdk - from ..const import CoreState from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HassioError, JobConditionException, JobException from ..host.const import HostFeature from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType +from ..utils.sentry import capture_exception from .const import JobCondition, JobExecutionLimit _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -157,7 +156,7 @@ class Job(CoreSysAttributes): raise err except Exception as err: _LOGGER.exception("Unhandled exception: %s", err) - sentry_sdk.capture_exception(err) + capture_exception(err) raise JobException() from err finally: if self.cleanup: diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index 81057aa22..14ff3fe40 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -5,8 +5,7 @@ import re from aiohttp import hdrs import attr -from ..api.const import HEADER_TOKEN, HEADER_TOKEN_OLD -from ..const import CoreState +from ..const import HEADER_TOKEN, HEADER_TOKEN_OLD, CoreState from ..coresys import CoreSys from ..exceptions import AddonConfigurationError diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 3c18f7649..52327644b 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -7,6 +7,7 @@ from ..coresys import CoreSysAttributes from ..exceptions import AddonsError, HomeAssistantError, ObserverError from ..jobs.decorator import Job, JobCondition from ..plugins.const import PLUGIN_UPDATE_CONDITIONS +from ..utils.sentry import capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -159,7 +160,7 @@ class Tasks(CoreSysAttributes): await self.sys_homeassistant.core.restart() except HomeAssistantError as err: _LOGGER.error("Home Assistant watchdog reanimation failed!") - self.sys_capture_exception(err) + capture_exception(err) finally: self._cache[HASS_WATCHDOG_API] = 0 @@ -265,7 +266,7 @@ class Tasks(CoreSysAttributes): await addon.restart() except AddonsError as err: _LOGGER.error("%s watchdog reanimation failed with %s", addon.slug, err) - self.sys_capture_exception(err) + capture_exception(err) finally: self._cache[addon.slug] = 0 diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 692ee03a7..98170a62d 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -26,6 +26,7 @@ from ..exceptions import ( from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job from ..utils.json import write_json_file +from ..utils.sentry import capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_AUDIO, @@ -188,7 +189,7 @@ class PluginAudio(PluginBase): await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of Audio failed") - self.sys_capture_exception(err) + capture_exception(err) def pulse_client(self, input_profile=None, output_profile=None) -> str: """Generate an /etc/pulse/client.conf data.""" diff --git a/supervisor/plugins/base.py b/supervisor/plugins/base.py index 2b8d2ed49..7237974df 100644 --- a/supervisor/plugins/base.py +++ b/supervisor/plugins/base.py @@ -14,6 +14,7 @@ from ..docker.interface import DockerInterface from ..docker.monitor import DockerContainerStateEvent from ..exceptions import DockerError, PluginError from ..utils.common import FileConfiguration +from ..utils.sentry import capture_exception from .const import WATCHDOG_MAX_ATTEMPTS, WATCHDOG_RETRY_SECONDS _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -129,7 +130,7 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes): except PluginError as err: attempts = attempts + 1 _LOGGER.error("Watchdog restart of %s plugin failed!", self.slug) - self.sys_capture_exception(err) + capture_exception(err) else: break diff --git a/supervisor/plugins/cli.py b/supervisor/plugins/cli.py index c9015e057..b4baec56c 100644 --- a/supervisor/plugins/cli.py +++ b/supervisor/plugins/cli.py @@ -18,6 +18,7 @@ from ..docker.stats import DockerStats from ..exceptions import CliError, CliJobError, CliUpdateError, DockerError from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job +from ..utils.sentry import capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_CLI, @@ -147,7 +148,7 @@ class PluginCli(PluginBase): await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of HA cli failed") - self.sys_capture_exception(err) + capture_exception(err) @Job( limit=JobExecutionLimit.THROTTLE_RATE_LIMIT, diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index b73faa9f5..e6c13e87d 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -31,6 +31,7 @@ from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils.json import write_json_file +from ..utils.sentry import capture_exception from ..validate import dns_url from .base import PluginBase from .const import ( @@ -421,7 +422,7 @@ class PluginDns(PluginBase): await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of CoreDNS failed") - self.sys_capture_exception(err) + capture_exception(err) def _write_resolv(self, resolv_conf: Path) -> None: """Update/Write resolv.conf file.""" diff --git a/supervisor/plugins/manager.py b/supervisor/plugins/manager.py index 732b0689d..fa6bd7f00 100644 --- a/supervisor/plugins/manager.py +++ b/supervisor/plugins/manager.py @@ -5,6 +5,7 @@ import logging from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HassioError from ..resolution.const import ContextType, IssueType, SuggestionType +from ..utils.sentry import capture_exception from .audio import PluginAudio from .base import PluginBase from .cli import PluginCli @@ -72,7 +73,7 @@ class PluginManager(CoreSysAttributes): reference=plugin.slug, suggestions=[SuggestionType.EXECUTE_REPAIR], ) - self.sys_capture_exception(err) + capture_exception(err) # Check requirements await self.sys_updater.reload() @@ -102,7 +103,7 @@ class PluginManager(CoreSysAttributes): ) except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Can't update plugin %s: %s", plugin.slug, err) - self.sys_capture_exception(err) + capture_exception(err) async def repair(self) -> None: """Repair Supervisor plugins.""" @@ -118,4 +119,4 @@ class PluginManager(CoreSysAttributes): await plugin.stop() except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Can't stop plugin %s: %s", plugin.slug, err) - self.sys_capture_exception(err) + capture_exception(err) diff --git a/supervisor/plugins/multicast.py b/supervisor/plugins/multicast.py index aa335bd16..68ba79958 100644 --- a/supervisor/plugins/multicast.py +++ b/supervisor/plugins/multicast.py @@ -20,6 +20,7 @@ from ..exceptions import ( ) from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job +from ..utils.sentry import capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_MULTICAST, @@ -142,7 +143,7 @@ class PluginMulticast(PluginBase): await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of Multicast failed") - self.sys_capture_exception(err) + capture_exception(err) @Job( limit=JobExecutionLimit.THROTTLE_RATE_LIMIT, diff --git a/supervisor/plugins/observer.py b/supervisor/plugins/observer.py index 171eb3570..e1e1be065 100644 --- a/supervisor/plugins/observer.py +++ b/supervisor/plugins/observer.py @@ -23,6 +23,7 @@ from ..exceptions import ( ) from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job +from ..utils.sentry import capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_OBSERVER, @@ -152,7 +153,7 @@ class PluginObserver(PluginBase): await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of HA observer failed") - self.sys_capture_exception(err) + capture_exception(err) @Job( limit=JobExecutionLimit.THROTTLE_RATE_LIMIT, diff --git a/supervisor/resolution/check.py b/supervisor/resolution/check.py index d1bad2b78..c9f5e7f60 100644 --- a/supervisor/resolution/check.py +++ b/supervisor/resolution/check.py @@ -6,6 +6,7 @@ from typing import Any from ..const import ATTR_CHECKS from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ResolutionNotFound +from ..utils.sentry import capture_exception from .checks.base import CheckBase from .validate import get_valid_modules @@ -59,6 +60,6 @@ class ResolutionCheck(CoreSysAttributes): await check() except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error during processing %s: %s", check.issue, err) - self.sys_capture_exception(err) + capture_exception(err) _LOGGER.info("System checks complete") diff --git a/supervisor/resolution/checks/dns_server.py b/supervisor/resolution/checks/dns_server.py index 47e785c4a..c88e4b2e9 100644 --- a/supervisor/resolution/checks/dns_server.py +++ b/supervisor/resolution/checks/dns_server.py @@ -9,6 +9,7 @@ from ...const import CoreState from ...coresys import CoreSys from ...jobs.const import JobCondition, JobExecutionLimit from ...jobs.decorator import Job +from ...utils.sentry import capture_exception from ..const import DNS_CHECK_HOST, ContextType, IssueType from .base import CheckBase @@ -39,7 +40,7 @@ class CheckDNSServer(CheckBase): ContextType.DNS_SERVER, reference=dns_servers[i], ) - self.sys_capture_exception(results[i]) + capture_exception(results[i]) @Job(conditions=[JobCondition.INTERNET_SYSTEM]) async def approve_check(self, reference: str | None = None) -> bool: diff --git a/supervisor/resolution/checks/dns_server_ipv6.py b/supervisor/resolution/checks/dns_server_ipv6.py index 95b1d43c1..13657021d 100644 --- a/supervisor/resolution/checks/dns_server_ipv6.py +++ b/supervisor/resolution/checks/dns_server_ipv6.py @@ -9,6 +9,7 @@ from ...const import CoreState from ...coresys import CoreSys from ...jobs.const import JobCondition, JobExecutionLimit from ...jobs.decorator import Job +from ...utils.sentry import capture_exception from ..const import DNS_CHECK_HOST, DNS_ERROR_NO_DATA, ContextType, IssueType from .base import CheckBase @@ -44,7 +45,7 @@ class CheckDNSServerIPv6(CheckBase): ContextType.DNS_SERVER, reference=dns_servers[i], ) - self.sys_capture_exception(results[i]) + capture_exception(results[i]) @Job(conditions=[JobCondition.INTERNET_SYSTEM]) async def approve_check(self, reference: str | None = None) -> bool: diff --git a/supervisor/resolution/evaluate.py b/supervisor/resolution/evaluate.py index b510a8b22..8c190ce31 100644 --- a/supervisor/resolution/evaluate.py +++ b/supervisor/resolution/evaluate.py @@ -4,6 +4,7 @@ import logging from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ResolutionNotFound +from ..utils.sentry import capture_exception from .const import UnhealthyReason, UnsupportedReason from .evaluations.base import EvaluateBase from .validate import get_valid_modules @@ -59,7 +60,7 @@ class ResolutionEvaluation(CoreSysAttributes): _LOGGER.warning( "Error during processing %s: %s", evaluation.reason, err ) - self.sys_capture_exception(err) + capture_exception(err) if any(reason in self.sys_resolution.unsupported for reason in UNHEALTHY): self.sys_resolution.unhealthy = UnhealthyReason.DOCKER diff --git a/supervisor/resolution/fixup.py b/supervisor/resolution/fixup.py index b567a3f78..1d34703e4 100644 --- a/supervisor/resolution/fixup.py +++ b/supervisor/resolution/fixup.py @@ -5,6 +5,7 @@ import logging from ..coresys import CoreSys, CoreSysAttributes from ..jobs.const import JobCondition from ..jobs.decorator import Job +from ..utils.sentry import capture_exception from .data import Issue, Suggestion from .fixups.base import FixupBase from .validate import get_valid_modules @@ -47,7 +48,7 @@ class ResolutionFixup(CoreSysAttributes): await fix() except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Error during processing %s: %s", fix.suggestion, err) - self.sys_capture_exception(err) + capture_exception(err) _LOGGER.info("System autofix complete") diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index 9a863466f..974d7a42a 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -30,6 +30,7 @@ from .jobs.const import JobCondition, JobExecutionLimit from .jobs.decorator import Job from .resolution.const import ContextType, IssueType from .utils.codenotary import calc_checksum +from .utils.sentry import capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -199,7 +200,7 @@ class Supervisor(CoreSysAttributes): self.sys_resolution.create_issue( IssueType.UPDATE_FAILED, ContextType.SUPERVISOR ) - self.sys_capture_exception(err) + capture_exception(err) raise SupervisorUpdateError( f"Update of Supervisor failed: {err!s}", _LOGGER.error ) from err diff --git a/supervisor/utils/log_format.py b/supervisor/utils/log_format.py index d5f439acb..65b0cf198 100644 --- a/supervisor/utils/log_format.py +++ b/supervisor/utils/log_format.py @@ -2,7 +2,7 @@ import logging import re -import sentry_sdk +from .sentry import capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -19,6 +19,6 @@ def format_message(message: str) -> str: return f"Port '{match.group(1)}' is already in use by something else on the host." except TypeError as err: _LOGGER.error("The type of message is not a string - %s", err) - sentry_sdk.capture_exception(err) + capture_exception(err) return message diff --git a/supervisor/utils/sentry.py b/supervisor/utils/sentry.py new file mode 100644 index 000000000..f1c303a31 --- /dev/null +++ b/supervisor/utils/sentry.py @@ -0,0 +1,60 @@ +"""Utilities for sentry.""" + +import logging + +import sentry_sdk +from sentry_sdk.integrations.aiohttp import AioHttpIntegration +from sentry_sdk.integrations.atexit import AtexitIntegration +from sentry_sdk.integrations.dedupe import DedupeIntegration +from sentry_sdk.integrations.excepthook import ExcepthookIntegration +from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.threading import ThreadingIntegration + +from ..const import SUPERVISOR_VERSION +from ..coresys import CoreSys +from ..misc.filter import filter_data + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def sentry_connected() -> bool: + """Is sentry connected.""" + return sentry_sdk.Hub.current.client and sentry_sdk.Hub.current.client.transport + + +def init_sentry(coresys: CoreSys) -> None: + """Initialize sentry client.""" + if not sentry_connected(): + _LOGGER.info("Initializing Supervisor Sentry") + sentry_sdk.init( + dsn="https://9c6ea70f49234442b4746e447b24747e@o427061.ingest.sentry.io/5370612", + before_send=lambda event, hint: filter_data(coresys, event, hint), + auto_enabling_integrations=False, + default_integrations=False, + integrations=[ + AioHttpIntegration(), + ExcepthookIntegration(), + DedupeIntegration(), + AtexitIntegration(), + ThreadingIntegration(), + LoggingIntegration(level=logging.WARNING, event_level=logging.CRITICAL), + ], + release=SUPERVISOR_VERSION, + max_breadcrumbs=30, + ) + + +def capture_exception(err: Exception) -> None: + """Capture an exception and send to sentry.""" + if sentry_connected(): + sentry_sdk.capture_exception(err) + + +def close_sentry() -> None: + """Close the current sentry client. + + This method is irreversible. A new client will have to be initialized to re-open connetion. + """ + if sentry_connected(): + _LOGGER.info("Closing connection to Supervisor Sentry") + sentry_sdk.Hub.current.client.close() diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 26e1b43f0..9f350961b 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -1,15 +1,23 @@ """Test addon manager.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import Mock, PropertyMock, patch from awesomeversion import AwesomeVersion import pytest from supervisor.addons.addon import Addon from supervisor.arch import CpuArch +from supervisor.const import AddonBoot, AddonStartup, AddonState from supervisor.coresys import CoreSys from supervisor.docker.addon import DockerAddon from supervisor.docker.interface import DockerInterface +from supervisor.exceptions import ( + AddonConfigurationError, + AddonsError, + DockerAPIError, + DockerNotFound, +) +from supervisor.utils import check_exception_chain from tests.common import load_json_fixture from tests.const import TEST_ADDON_SLUG @@ -65,3 +73,60 @@ async def test_image_added_removed_on_update( await install_addon_ssh.update() build.assert_called_once_with(AwesomeVersion("11.0.0")) install.assert_not_called() + + +@pytest.mark.parametrize("err", [DockerAPIError, DockerNotFound]) +async def test_addon_boot_system_error( + coresys: CoreSys, install_addon_ssh: Addon, capture_exception: Mock, err +): + """Test system errors during addon boot.""" + install_addon_ssh.boot = AddonBoot.AUTO + with patch.object(Addon, "write_options"), patch.object( + DockerAddon, "run", side_effect=err + ): + await coresys.addons.boot(AddonStartup.APPLICATION) + + assert install_addon_ssh.boot == AddonBoot.MANUAL + capture_exception.assert_not_called() + + +async def test_addon_boot_user_error( + coresys: CoreSys, install_addon_ssh: Addon, capture_exception: Mock +): + """Test user error during addon boot.""" + install_addon_ssh.boot = AddonBoot.AUTO + with patch.object(Addon, "write_options", side_effect=AddonConfigurationError): + await coresys.addons.boot(AddonStartup.APPLICATION) + + assert install_addon_ssh.boot == AddonBoot.MANUAL + capture_exception.assert_not_called() + + +async def test_addon_boot_other_error( + coresys: CoreSys, install_addon_ssh: Addon, capture_exception: Mock +): + """Test other errors captured during addon boot.""" + install_addon_ssh.boot = AddonBoot.AUTO + err = OSError() + with patch.object(Addon, "write_options"), patch.object( + DockerAddon, "run", side_effect=err + ): + await coresys.addons.boot(AddonStartup.APPLICATION) + + assert install_addon_ssh.boot == AddonBoot.AUTO + capture_exception.assert_called_once_with(err) + + +async def test_addon_shutdown_error( + coresys: CoreSys, install_addon_ssh: Addon, capture_exception: Mock +): + """Test errors captured during addon shutdown.""" + install_addon_ssh.state = AddonState.STARTED + with patch.object(DockerAddon, "stop", side_effect=DockerNotFound()): + await coresys.addons.shutdown(AddonStartup.APPLICATION) + + assert install_addon_ssh.state == AddonState.ERROR + capture_exception.assert_called_once() + assert check_exception_chain( + capture_exception.call_args[0][0], (AddonsError, DockerNotFound) + ) diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index a26b142a0..50569a561 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -124,10 +124,25 @@ async def test_api_supervisor_options_diagnostics( await coresys.dbus.agent.connect(coresys.dbus.bus) dbus.clear() - response = await api_client.post("/supervisor/options", json={"diagnostics": True}) - await asyncio.sleep(0) + with patch("supervisor.utils.sentry.sentry_sdk.init") as sentry_init: + response = await api_client.post( + "/supervisor/options", json={"diagnostics": True} + ) + assert response.status == 200 + sentry_init.assert_called_once() - assert response.status == 200 + await asyncio.sleep(0) + assert dbus == ["/io/hass/os-io.hass.os.Diagnostics"] + + dbus.clear() + with patch("supervisor.api.supervisor.close_sentry") as close_sentry: + response = await api_client.post( + "/supervisor/options", json={"diagnostics": False} + ) + assert response.status == 200 + close_sentry.assert_called_once() + + await asyncio.sleep(0) assert dbus == ["/io/hass/os-io.hass.os.Diagnostics"] diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index b985a325d..74f91280f 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1,11 +1,13 @@ """Test BackupManager class.""" -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch +from supervisor.addons.addon import Addon from supervisor.backups.const import BackupType from supervisor.backups.manager import BackupManager from supervisor.const import FOLDER_HOMEASSISTANT, FOLDER_SHARE, CoreState from supervisor.coresys import CoreSys +from supervisor.exceptions import AddonsError, DockerError from tests.const import TEST_ADDON_SLUG @@ -321,3 +323,34 @@ async def test_fail_invalid_partial_backup( type(coresys.supervisor), "version", new=PropertyMock(return_value="2022.08.3") ): assert await manager.do_restore_partial(backup_instance) is False + + +async def test_backup_error( + coresys: CoreSys, + backup_mock: MagicMock, + install_addon_ssh: Addon, + capture_exception: Mock, +): + """Test error captured when backup fails.""" + coresys.core.state = CoreState.RUNNING + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + backup_mock.return_value.store_addons.side_effect = (err := AddonsError()) + await coresys.backups.do_backup_full() + + capture_exception.assert_called_once_with(err) + + +async def test_restore_error( + coresys: CoreSys, full_backup_mock: MagicMock, capture_exception: Mock +): + """Test restoring full Backup.""" + coresys.core.state = CoreState.RUNNING + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + coresys.homeassistant.core.start = AsyncMock(return_value=None) + + backup_instance = full_backup_mock.return_value + backup_instance.restore_dockerconfig.side_effect = (err := DockerError()) + await coresys.backups.do_restore_full(backup_instance) + + capture_exception.assert_called_once_with(err) diff --git a/tests/conftest.py b/tests/conftest.py index 44b7fdf99..580634fc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from functools import partial from inspect import unwrap from pathlib import Path import re -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch from uuid import uuid4 from aiohttp import web @@ -302,7 +302,7 @@ async def coresys( ) -> CoreSys: """Create a CoreSys Mock.""" with patch("supervisor.bootstrap.initialize_system"), patch( - "supervisor.bootstrap.setup_diagnostics" + "supervisor.utils.sentry.sentry_sdk.init" ): coresys_obj = await initialize_coresys() @@ -580,3 +580,12 @@ async def docker_logs(docker: DockerAPI) -> MagicMock: os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"} yield container_mock.logs + + +@pytest.fixture +async def capture_exception() -> Mock: + """Mock capture exception method for testing.""" + with patch("supervisor.utils.sentry.sentry_connected", return_value=True), patch( + "supervisor.utils.sentry.sentry_sdk.capture_exception" + ) as capture_exception: + yield capture_exception diff --git a/tests/dbus/network/test_network_manager.py b/tests/dbus/network/test_network_manager.py index d29293fed..ddae41cf8 100644 --- a/tests/dbus/network/test_network_manager.py +++ b/tests/dbus/network/test_network_manager.py @@ -1,7 +1,7 @@ """Test NetworkInterface.""" import asyncio import logging -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from dbus_fast.aio.message_bus import MessageBus import pytest @@ -114,7 +114,9 @@ async def test_removed_devices_disconnect(network_manager: NetworkManager): async def test_handling_bad_devices( - network_manager: NetworkManager, caplog: pytest.LogCaptureFixture + network_manager: NetworkManager, + caplog: pytest.LogCaptureFixture, + capture_exception: Mock, ): """Test handling of bad and disappearing devices.""" caplog.clear() @@ -135,14 +137,12 @@ async def test_handling_bad_devices( # Unparseable introspections shouldn't happen, this one is logged and captured await network_manager.update() - with patch.object( - DBus, "_init_proxy", side_effect=(err := DBusParseError()) - ), patch("supervisor.dbus.network.sentry_sdk.capture_exception") as capture: + with patch.object(DBus, "_init_proxy", side_effect=(err := DBusParseError())): await network_manager.update( {"Devices": [device := "/org/freedesktop/NetworkManager/Devices/102"]} ) assert f"Error while processing {device}" in caplog.text - capture.assert_called_once_with(err) + capture_exception.assert_called_once_with(err) # We should be able to debug these situations if necessary caplog.set_level(logging.DEBUG, "supervisor.dbus.network") diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index d789464fb..26a4cb60d 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -1,14 +1,21 @@ """Test docker addon setup.""" -from unittest.mock import MagicMock, PropertyMock, patch +from ipaddress import IPv4Address +from unittest.mock import MagicMock, Mock, PropertyMock, patch +from docker.errors import NotFound import pytest from supervisor.addons import validate as vd from supervisor.addons.addon import Addon from supervisor.addons.model import Data +from supervisor.addons.options import AddonOptions from supervisor.const import SYSTEMD_JOURNAL_PERSISTENT, SYSTEMD_JOURNAL_VOLATILE from supervisor.coresys import CoreSys from supervisor.docker.addon import DockerAddon +from supervisor.exceptions import CoreDNSError, DockerNotFound +from supervisor.plugins.dns import PluginDns +from supervisor.resolution.const import ContextType, IssueType +from supervisor.resolution.data import Issue from ..common import load_json_fixture @@ -32,7 +39,7 @@ def fixture_addonsdata_user() -> dict[str, Data]: yield mock -@pytest.fixture(name="os_environ", autouse=True) +@pytest.fixture(name="os_environ") def fixture_os_environ(): """Mock os.environ.""" with patch("supervisor.config.os.environ") as mock: @@ -52,7 +59,9 @@ def get_docker_addon( return docker_addon -def test_base_volumes_included(coresys: CoreSys, addonsdata_system: dict[str, Data]): +def test_base_volumes_included( + coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ +): """Dev and data volumes always included.""" docker_addon = get_docker_addon( coresys, addonsdata_system, "basic-addon-config.json" @@ -72,7 +81,7 @@ def test_base_volumes_included(coresys: CoreSys, addonsdata_system: dict[str, Da def test_addon_map_folder_defaults( - coresys: CoreSys, addonsdata_system: dict[str, Data] + coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ ): """Validate defaults for mapped folders in addons.""" docker_addon = get_docker_addon( @@ -96,7 +105,9 @@ def test_addon_map_folder_defaults( assert str(docker_addon.sys_config.path_extern_share) not in volumes -def test_journald_addon(coresys: CoreSys, addonsdata_system: dict[str, Data]): +def test_journald_addon( + coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ +): """Validate volume for journald option.""" docker_addon = get_docker_addon( coresys, addonsdata_system, "journald-addon-config.json" @@ -115,7 +126,9 @@ def test_journald_addon(coresys: CoreSys, addonsdata_system: dict[str, Data]): assert volumes.get(str(SYSTEMD_JOURNAL_VOLATILE)).get("mode") == "ro" -def test_not_journald_addon(coresys: CoreSys, addonsdata_system: dict[str, Data]): +def test_not_journald_addon( + coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ +): """Validate journald option defaults off.""" docker_addon = get_docker_addon( coresys, addonsdata_system, "basic-addon-config.json" @@ -123,3 +136,68 @@ def test_not_journald_addon(coresys: CoreSys, addonsdata_system: dict[str, Data] volumes = docker_addon.volumes assert str(SYSTEMD_JOURNAL_PERSISTENT) not in volumes + + +async def test_addon_run_docker_error( + coresys: CoreSys, + addonsdata_system: dict[str, Data], + capture_exception: Mock, + os_environ, +): + """Test docker error when addon is run.""" + await coresys.dbus.timedate.connect(coresys.dbus.bus) + coresys.docker.docker.containers.create.side_effect = NotFound("Missing") + docker_addon = get_docker_addon( + coresys, addonsdata_system, "basic-addon-config.json" + ) + + with patch.object(DockerAddon, "_stop"), patch.object( + AddonOptions, "validate", new=PropertyMock(return_value=lambda _: None) + ), pytest.raises(DockerNotFound): + await docker_addon.run() + + assert ( + Issue(IssueType.MISSING_IMAGE, ContextType.ADDON, reference="test_addon") + in coresys.resolution.issues + ) + capture_exception.assert_not_called() + + +async def test_addon_run_add_host_error( + coresys: CoreSys, + addonsdata_system: dict[str, Data], + capture_exception: Mock, + os_environ, +): + """Test error adding host when addon is run.""" + await coresys.dbus.timedate.connect(coresys.dbus.bus) + docker_addon = get_docker_addon( + coresys, addonsdata_system, "basic-addon-config.json" + ) + + with patch.object(DockerAddon, "_stop"), patch.object( + AddonOptions, "validate", new=PropertyMock(return_value=lambda _: None) + ), patch.object(PluginDns, "add_host", side_effect=(err := CoreDNSError())): + await docker_addon.run() + + capture_exception.assert_called_once_with(err) + + +async def test_addon_stop_delete_host_error( + coresys: CoreSys, + addonsdata_system: dict[str, Data], + capture_exception: Mock, +): + """Test error deleting host when addon is stopped.""" + docker_addon = get_docker_addon( + coresys, addonsdata_system, "basic-addon-config.json" + ) + + with patch.object( + DockerAddon, + "ip_address", + new=PropertyMock(return_value=IPv4Address("172.30.33.1")), + ), patch.object(PluginDns, "delete_host", side_effect=(err := CoreDNSError())): + await docker_addon.stop() + + capture_exception.assert_called_once_with(err) diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index 8df0925b6..1dd95b414 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -206,3 +206,17 @@ async def test_attach_total_failure(coresys: CoreSys): DockerError ): await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3")) + + +@pytest.mark.parametrize("err", [DockerException(), RequestException()]) +async def test_image_pull_fail( + coresys: CoreSys, capture_exception: Mock, err: Exception +): + """Test failure to pull image.""" + coresys.docker.images.pull.side_effect = err + with pytest.raises(DockerError): + await coresys.homeassistant.core.instance.install( + AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64 + ) + + capture_exception.assert_called_once_with(err) diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py index 1bf5c99a2..c2aba6bde 100644 --- a/tests/homeassistant/test_core.py +++ b/tests/homeassistant/test_core.py @@ -1,10 +1,20 @@ """Test Home Assistant core.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest +from supervisor.const import CpuArch from supervisor.coresys import CoreSys -from supervisor.exceptions import AudioUpdateError, HomeAssistantJobError +from supervisor.docker.homeassistant import DockerHomeAssistant +from supervisor.docker.interface import DockerInterface +from supervisor.exceptions import ( + AudioUpdateError, + CodeNotaryError, + DockerError, + HomeAssistantJobError, +) +from supervisor.homeassistant.core import HomeAssistantCore +from supervisor.updater import Updater async def test_update_fails_if_out_of_date(coresys: CoreSys): @@ -24,3 +34,99 @@ async def test_update_fails_if_out_of_date(coresys: CoreSys): HomeAssistantJobError ): await coresys.homeassistant.core.update() + + +async def test_install_landingpage_docker_error( + coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture +): + """Test install landing page fails due to docker error.""" + coresys.security.force = True + with patch.object( + DockerHomeAssistant, "attach", side_effect=DockerError + ), patch.object( + Updater, "image_homeassistant", new=PropertyMock(return_value="homeassistant") + ), patch.object( + DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64) + ), patch( + "supervisor.homeassistant.core.asyncio.sleep" + ) as sleep, patch( + "supervisor.security.module.cas_validate", + side_effect=[CodeNotaryError, None], + ): + await coresys.homeassistant.core.install_landingpage() + sleep.assert_awaited_once_with(30) + + assert "Fails install landingpage, retry after 30sec" in caplog.text + capture_exception.assert_not_called() + + +async def test_install_landingpage_other_error( + coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture +): + """Test install landing page fails due to other error.""" + coresys.docker.images.pull.side_effect = [(err := OSError()), MagicMock()] + + with patch.object( + DockerHomeAssistant, "attach", side_effect=DockerError + ), patch.object( + Updater, "image_homeassistant", new=PropertyMock(return_value="homeassistant") + ), patch.object( + DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64) + ), patch( + "supervisor.homeassistant.core.asyncio.sleep" + ) as sleep: + await coresys.homeassistant.core.install_landingpage() + sleep.assert_awaited_once_with(30) + + assert "Fails install landingpage, retry after 30sec" in caplog.text + capture_exception.assert_called_once_with(err) + + +async def test_install_docker_error( + coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture +): + """Test install fails due to docker error.""" + coresys.security.force = True + with patch.object(HomeAssistantCore, "_start"), patch.object( + DockerHomeAssistant, "cleanup" + ), patch.object( + Updater, "image_homeassistant", new=PropertyMock(return_value="homeassistant") + ), patch.object( + Updater, "version_homeassistant", new=PropertyMock(return_value="2022.7.3") + ), patch.object( + DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64) + ), patch( + "supervisor.homeassistant.core.asyncio.sleep" + ) as sleep, patch( + "supervisor.security.module.cas_validate", + side_effect=[CodeNotaryError, None], + ): + await coresys.homeassistant.core.install() + sleep.assert_awaited_once_with(30) + + assert "Error on Home Assistant installation. Retry in 30sec" in caplog.text + capture_exception.assert_not_called() + + +async def test_install_other_error( + coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture +): + """Test install fails due to other error.""" + coresys.docker.images.pull.side_effect = [(err := OSError()), MagicMock()] + + with patch.object(HomeAssistantCore, "_start"), patch.object( + DockerHomeAssistant, "cleanup" + ), patch.object( + Updater, "image_homeassistant", new=PropertyMock(return_value="homeassistant") + ), patch.object( + Updater, "version_homeassistant", new=PropertyMock(return_value="2022.7.3") + ), patch.object( + DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64) + ), patch( + "supervisor.homeassistant.core.asyncio.sleep" + ) as sleep: + await coresys.homeassistant.core.install() + sleep.assert_awaited_once_with(30) + + assert "Error on Home Assistant installation. Retry in 30sec" in caplog.text + capture_exception.assert_called_once_with(err) diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 1954b7fde..c3f8ca296 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access,import-error import asyncio from datetime import timedelta -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from aiohttp.client_exceptions import ClientError import pytest @@ -512,3 +512,26 @@ async def test_unhealthy(coresys: CoreSys, caplog: pytest.LogCaptureFixture): coresys.jobs.ignore_conditions = [JobCondition.HEALTHY] assert await test.execute() + + +async def test_unhandled_exception(coresys: CoreSys, capture_exception: Mock): + """Test an unhandled exception from job.""" + err = OSError() + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + + @Job(conditions=[JobCondition.HEALTHY]) + async def execute(self) -> None: + """Execute the class method.""" + raise err + + test = TestClass(coresys) + with pytest.raises(JobException): + await test.execute() + + capture_exception.assert_called_once_with(err) diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index 802fc23a7..7a87036c6 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -1,19 +1,21 @@ """Test base plugin functionality.""" import asyncio -from unittest.mock import PropertyMock, patch +from unittest.mock import Mock, PropertyMock, patch from awesomeversion import AwesomeVersion import pytest -from supervisor.const import BusEvent +from supervisor.const import BusEvent, CpuArch from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState +from supervisor.docker.interface import DockerInterface from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import ( AudioError, AudioJobError, CliError, CliJobError, + CodeNotaryUntrusted, CoreDNSError, CoreDNSJobError, DockerError, @@ -30,6 +32,7 @@ from supervisor.plugins.cli import PluginCli from supervisor.plugins.dns import PluginDns from supervisor.plugins.multicast import PluginMulticast from supervisor.plugins.observer import PluginObserver +from supervisor.utils import check_exception_chain @pytest.fixture(name="plugin") @@ -159,16 +162,16 @@ async def test_plugin_watchdog(coresys: CoreSys, plugin: PluginBase) -> None: @pytest.mark.parametrize( "plugin,error", [ - (PluginAudio, AudioError), - (PluginCli, CliError), - (PluginDns, CoreDNSError), - (PluginMulticast, MulticastError), - (PluginObserver, ObserverError), + (PluginAudio, AudioError()), + (PluginCli, CliError()), + (PluginDns, CoreDNSError()), + (PluginMulticast, MulticastError()), + (PluginObserver, ObserverError()), ], indirect=["plugin"], ) async def test_plugin_watchdog_rebuild_on_failure( - coresys: CoreSys, plugin: PluginBase, error: PluginError + coresys: CoreSys, capture_exception: Mock, plugin: PluginBase, error: PluginError ) -> None: """Test plugin watchdog rebuilds if start fails.""" with patch.object(type(plugin.instance), "attach"), patch.object( @@ -201,6 +204,8 @@ async def test_plugin_watchdog_rebuild_on_failure( start.assert_called_once() rebuild.assert_called_once() + capture_exception.assert_called_once_with(error) + @pytest.mark.parametrize( "plugin", @@ -331,3 +336,25 @@ async def test_update_fails_if_out_of_date( type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) ), pytest.raises(error): await plugin.update() + + +@pytest.mark.parametrize( + "plugin", + [PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver], + indirect=True, +) +async def test_repair_failed( + coresys: CoreSys, capture_exception: Mock, plugin: PluginBase +): + """Test repair failed.""" + with patch.object( + DockerInterface, "exists", return_value=mock_is_running(False) + ), patch.object( + DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64) + ), patch( + "supervisor.security.module.cas_validate", side_effect=CodeNotaryUntrusted + ): + await plugin.repair() + + capture_exception.assert_called_once() + assert check_exception_chain(capture_exception.call_args[0][0], CodeNotaryUntrusted) diff --git a/tests/resolution/check/test_check_dns_server_failure.py b/tests/resolution/check/test_check_dns_server_failure.py index 9096ccf2f..68ed4f6d8 100644 --- a/tests/resolution/check/test_check_dns_server_failure.py +++ b/tests/resolution/check/test_check_dns_server_failure.py @@ -1,5 +1,5 @@ """Test check DNS Servers for failures.""" -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch from aiodns.error import DNSError import pytest @@ -27,7 +27,7 @@ async def test_base(coresys: CoreSys): assert dns_server.enabled -async def test_check(coresys: CoreSys, dns_query: AsyncMock): +async def test_check(coresys: CoreSys, dns_query: AsyncMock, capture_exception: Mock): """Test check for DNS server failures.""" dns_server = CheckDNSServer(coresys) coresys.core.state = CoreState.RUNNING @@ -50,7 +50,7 @@ async def test_check(coresys: CoreSys, dns_query: AsyncMock): coresys.plugins.dns.servers = [] assert dns_server.dns_servers == ["dns://192.168.30.1"] - dns_query.side_effect = DNSError() + dns_query.side_effect = (err := DNSError()) await dns_server.run_check.__wrapped__(dns_server) dns_query.assert_called_once_with("_checkdns.home-assistant.io", "A") @@ -58,6 +58,7 @@ async def test_check(coresys: CoreSys, dns_query: AsyncMock): assert coresys.resolution.issues[0].type is IssueType.DNS_SERVER_FAILED assert coresys.resolution.issues[0].context is ContextType.DNS_SERVER assert coresys.resolution.issues[0].reference == "dns://192.168.30.1" + capture_exception.assert_called_once_with(err) async def test_approve(coresys: CoreSys, dns_query: AsyncMock): diff --git a/tests/resolution/check/test_check_dns_server_ipv6_error.py b/tests/resolution/check/test_check_dns_server_ipv6_error.py index ecd1e0b90..744b249d9 100644 --- a/tests/resolution/check/test_check_dns_server_ipv6_error.py +++ b/tests/resolution/check/test_check_dns_server_ipv6_error.py @@ -1,5 +1,5 @@ """Test check DNS Servers for IPv6 errors.""" -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch from aiodns.error import DNSError import pytest @@ -27,7 +27,7 @@ async def test_base(coresys: CoreSys): assert dns_server_ipv6.enabled -async def test_check(coresys: CoreSys, dns_query: AsyncMock): +async def test_check(coresys: CoreSys, dns_query: AsyncMock, capture_exception: Mock): """Test check for DNS server IPv6 errors.""" dns_server_ipv6 = CheckDNSServerIPv6(coresys) coresys.core.state = CoreState.RUNNING @@ -56,7 +56,7 @@ async def test_check(coresys: CoreSys, dns_query: AsyncMock): assert len(coresys.resolution.issues) == 0 dns_query.reset_mock() - dns_query.side_effect = DNSError(4, "Domain name not found") + dns_query.side_effect = (err := DNSError(4, "Domain name not found")) await dns_server_ipv6.run_check.__wrapped__(dns_server_ipv6) dns_query.assert_called_once_with("_checkdns.home-assistant.io", "AAAA") @@ -64,6 +64,7 @@ async def test_check(coresys: CoreSys, dns_query: AsyncMock): assert coresys.resolution.issues[0].type is IssueType.DNS_SERVER_IPV6_ERROR assert coresys.resolution.issues[0].context is ContextType.DNS_SERVER assert coresys.resolution.issues[0].reference == "dns://192.168.30.1" + capture_exception.assert_called_once_with(err) async def test_approve(coresys: CoreSys, dns_query: AsyncMock): diff --git a/tests/resolution/test_check.py b/tests/resolution/test_check.py new file mode 100644 index 000000000..ec2018046 --- /dev/null +++ b/tests/resolution/test_check.py @@ -0,0 +1,21 @@ +"""Test checks.""" + +from unittest.mock import Mock, patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.checks.core_security import CheckCoreSecurity +from supervisor.utils import check_exception_chain + + +async def test_check_system_error(coresys: CoreSys, capture_exception: Mock): + """Test error while checking system.""" + coresys.core.state = CoreState.STARTUP + + with patch.object(CheckCoreSecurity, "run_check", side_effect=ValueError), patch( + "shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3)) + ): + await coresys.resolution.check.check_system() + + capture_exception.assert_called_once() + assert check_exception_chain(capture_exception.call_args[0][0], ValueError) diff --git a/tests/resolution/test_evaluation.py b/tests/resolution/test_evaluation.py new file mode 100644 index 000000000..ee2973de4 --- /dev/null +++ b/tests/resolution/test_evaluation.py @@ -0,0 +1,21 @@ +"""Test evaluations.""" + +from unittest.mock import Mock, patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.utils import check_exception_chain + + +async def test_evaluate_system_error(coresys: CoreSys, capture_exception: Mock): + """Test error while evaluating system.""" + coresys.core.state = CoreState.RUNNING + + with patch( + "supervisor.resolution.evaluations.source_mods.calc_checksum_path_sourcecode", + side_effect=OSError, + ): + await coresys.resolution.evaluate.evaluate_system() + + capture_exception.assert_called_once() + assert check_exception_chain(capture_exception.call_args[0][0], OSError) diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 14162d527..9ff4b222e 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -1,12 +1,17 @@ """Test supervisor object.""" from datetime import timedelta -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from aiohttp.client_exceptions import ClientError +from awesomeversion import AwesomeVersion import pytest from supervisor.coresys import CoreSys +from supervisor.docker.supervisor import DockerSupervisor +from supervisor.exceptions import DockerError, SupervisorUpdateError +from supervisor.resolution.const import ContextType, IssueType +from supervisor.resolution.data import Issue from supervisor.supervisor import Supervisor @@ -63,3 +68,18 @@ async def test_connectivity_check_throttling( await coresys.supervisor.check_connectivity() assert websession.head.call_count == call_count + + +async def test_update_failed(coresys: CoreSys, capture_exception: Mock): + """Test update failure.""" + err = DockerError() + with patch.object(DockerSupervisor, "install", side_effect=err), patch.object( + type(coresys.supervisor), "update_apparmor" + ), pytest.raises(SupervisorUpdateError): + await coresys.supervisor.update(AwesomeVersion("1.0")) + + capture_exception.assert_called_once_with(err) + assert ( + Issue(IssueType.UPDATE_FAILED, ContextType.SUPERVISOR) + in coresys.resolution.issues + )