diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index aaf156ded..10c3d613e 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -3,6 +3,8 @@ from enum import IntEnum, StrEnum from socket import AF_INET, AF_INET6 +from .enum import DBusIntEnum, DBusStrEnum + DBUS_NAME_HAOS = "io.hass.os" DBUS_NAME_HOSTNAME = "org.freedesktop.hostname1" DBUS_NAME_LOGIND = "org.freedesktop.login1" @@ -208,7 +210,7 @@ DBUS_ATTR_WWN = "WWN" DBUS_ERR_SYSTEMD_NO_SUCH_UNIT = "org.freedesktop.systemd1.NoSuchUnit" -class RaucState(StrEnum): +class RaucState(DBusStrEnum): """Rauc slot states.""" GOOD = "good" @@ -216,7 +218,7 @@ class RaucState(StrEnum): ACTIVE = "active" -class InterfaceMethod(StrEnum): +class InterfaceMethod(DBusStrEnum): """Interface method simple.""" AUTO = "auto" @@ -225,7 +227,7 @@ class InterfaceMethod(StrEnum): LINK_LOCAL = "link-local" -class InterfaceAddrGenMode(IntEnum): +class InterfaceAddrGenMode(DBusIntEnum): """Interface addr_gen_mode.""" EUI64 = 0 @@ -234,7 +236,7 @@ class InterfaceAddrGenMode(IntEnum): DEFAULT = 3 -class InterfaceIp6Privacy(IntEnum): +class InterfaceIp6Privacy(DBusIntEnum): """Interface ip6_privacy.""" DEFAULT = -1 @@ -243,14 +245,14 @@ class InterfaceIp6Privacy(IntEnum): ENABLED = 2 -class ConnectionType(StrEnum): +class ConnectionType(DBusStrEnum): """Connection type.""" ETHERNET = "802-3-ethernet" WIRELESS = "802-11-wireless" -class ConnectionState(IntEnum): +class ConnectionState(DBusIntEnum): """Connection states. https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState @@ -280,7 +282,7 @@ class ConnectionStateFlags(IntEnum): EXTERNAL = 0x80 -class ConnectivityState(IntEnum): +class ConnectivityState(DBusIntEnum): """Network connectvity. https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState @@ -293,7 +295,7 @@ class ConnectivityState(IntEnum): CONNECTIVITY_FULL = 4 -class DeviceType(IntEnum): +class DeviceType(DBusIntEnum): """Device types. https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceType @@ -310,7 +312,7 @@ class DeviceType(IntEnum): LOOPBACK = 32 -class WirelessMethodType(IntEnum): +class WirelessMethodType(DBusIntEnum): """Device Type.""" UNKNOWN = 0 @@ -327,7 +329,7 @@ class DNSAddressFamily(IntEnum): INET6 = AF_INET6 -class MulticastProtocolEnabled(StrEnum): +class MulticastProtocolEnabled(DBusStrEnum): """Multicast protocol enabled or resolve.""" YES = "yes" @@ -335,7 +337,7 @@ class MulticastProtocolEnabled(StrEnum): RESOLVE = "resolve" -class MulticastDnsValue(IntEnum): +class MulticastDnsValue(DBusIntEnum): """Connection MulticastDNS (mdns/llmnr) values.""" DEFAULT = -1 @@ -344,7 +346,7 @@ class MulticastDnsValue(IntEnum): ANNOUNCE = 2 -class DNSOverTLSEnabled(StrEnum): +class DNSOverTLSEnabled(DBusStrEnum): """DNS over TLS enabled.""" YES = "yes" @@ -352,7 +354,7 @@ class DNSOverTLSEnabled(StrEnum): OPPORTUNISTIC = "opportunistic" -class DNSSECValidation(StrEnum): +class DNSSECValidation(DBusStrEnum): """DNSSEC validation enforced.""" YES = "yes" @@ -360,7 +362,7 @@ class DNSSECValidation(StrEnum): ALLOW_DOWNGRADE = "allow-downgrade" -class DNSStubListenerEnabled(StrEnum): +class DNSStubListenerEnabled(DBusStrEnum): """DNS stub listener enabled.""" YES = "yes" @@ -369,7 +371,7 @@ class DNSStubListenerEnabled(StrEnum): UDP_ONLY = "udp" -class ResolvConfMode(StrEnum): +class ResolvConfMode(DBusStrEnum): """Resolv.conf management mode.""" FOREIGN = "foreign" @@ -398,7 +400,7 @@ class StartUnitMode(StrEnum): ISOLATE = "isolate" -class UnitActiveState(StrEnum): +class UnitActiveState(DBusStrEnum): """Active state of a systemd unit.""" ACTIVE = "active" diff --git a/supervisor/dbus/enum.py b/supervisor/dbus/enum.py new file mode 100644 index 000000000..3c36d04ff --- /dev/null +++ b/supervisor/dbus/enum.py @@ -0,0 +1,56 @@ +"""D-Bus tolerant enum base classes. + +D-Bus services (systemd, NetworkManager, RAUC, UDisks2) can introduce new enum +values at any time via OS updates. Standard enum construction raises ValueError +for unknown values. These base classes use Python's _missing_ hook to create +pseudo-members for unknown values, preventing crashes while preserving the +original value for logging and debugging. +""" + +from enum import IntEnum, StrEnum +import logging + +from ..utils.sentry import fire_and_forget_capture_message + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +_reported: set[tuple[str, str | int]] = set() + + +def _report_unknown_value(cls: type, value: str | int) -> None: + """Log and report an unknown D-Bus enum value to Sentry.""" + msg = f"Unknown {cls.__name__} value received from D-Bus: {value}" + _LOGGER.warning(msg) + + key = (cls.__name__, value) + if key not in _reported: + _reported.add(key) + fire_and_forget_capture_message(msg) + + +class DBusStrEnum(StrEnum): + """StrEnum that tolerates unknown values from D-Bus.""" + + @classmethod + def _missing_(cls, value: object) -> "DBusStrEnum | None": + if not isinstance(value, str): + return None + _report_unknown_value(cls, value) + obj = str.__new__(cls, value) + obj._name_ = value + obj._value_ = value + return obj + + +class DBusIntEnum(IntEnum): + """IntEnum that tolerates unknown values from D-Bus.""" + + @classmethod + def _missing_(cls, value: object) -> "DBusIntEnum | None": + if not isinstance(value, int): + return None + _report_unknown_value(cls, value) + obj = int.__new__(cls, value) + obj._name_ = f"UNKNOWN_{value}" + obj._value_ = value + return obj diff --git a/supervisor/dbus/network/interface.py b/supervisor/dbus/network/interface.py index f7528c58a..f37358340 100644 --- a/supervisor/dbus/network/interface.py +++ b/supervisor/dbus/network/interface.py @@ -60,15 +60,7 @@ class NetworkInterface(DBusInterfaceProxy): @dbus_property def type(self) -> DeviceType: """Return interface type.""" - try: - return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE]) - except ValueError: - _LOGGER.debug( - "Unknown device type %s for %s, treating as UNKNOWN", - self.properties[DBUS_ATTR_DEVICE_TYPE], - self.object_path, - ) - return DeviceType.UNKNOWN + return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE]) @property @dbus_property diff --git a/supervisor/dbus/resolved.py b/supervisor/dbus/resolved.py index 9bf53e95d..857cb9c48 100644 --- a/supervisor/dbus/resolved.py +++ b/supervisor/dbus/resolved.py @@ -103,19 +103,19 @@ class Resolved(DBusInterfaceProxy): @dbus_property def dns_over_tls(self) -> DNSOverTLSEnabled | None: """Return DNS over TLS enabled.""" - return self.properties[DBUS_ATTR_DNS_OVER_TLS] + return DNSOverTLSEnabled(self.properties[DBUS_ATTR_DNS_OVER_TLS]) @property @dbus_property def dns_stub_listener(self) -> DNSStubListenerEnabled | None: """Return DNS stub listener enabled on port 53.""" - return self.properties[DBUS_ATTR_DNS_STUB_LISTENER] + return DNSStubListenerEnabled(self.properties[DBUS_ATTR_DNS_STUB_LISTENER]) @property @dbus_property def dnssec(self) -> DNSSECValidation | None: """Return DNSSEC validation enforced.""" - return self.properties[DBUS_ATTR_DNSSEC] + return DNSSECValidation(self.properties[DBUS_ATTR_DNSSEC]) @property @dbus_property @@ -159,7 +159,7 @@ class Resolved(DBusInterfaceProxy): @dbus_property def llmnr(self) -> MulticastProtocolEnabled | None: """Return LLMNR enabled.""" - return self.properties[DBUS_ATTR_LLMNR] + return MulticastProtocolEnabled(self.properties[DBUS_ATTR_LLMNR]) @property @dbus_property @@ -171,13 +171,13 @@ class Resolved(DBusInterfaceProxy): @dbus_property def multicast_dns(self) -> MulticastProtocolEnabled | None: """Return MDNS enabled.""" - return self.properties[DBUS_ATTR_MULTICAST_DNS] + return MulticastProtocolEnabled(self.properties[DBUS_ATTR_MULTICAST_DNS]) @property @dbus_property def resolv_conf_mode(self) -> ResolvConfMode | None: """Return how /etc/resolv.conf managed on host.""" - return self.properties[DBUS_ATTR_RESOLV_CONF_MODE] + return ResolvConfMode(self.properties[DBUS_ATTR_RESOLV_CONF_MODE]) @property @dbus_property diff --git a/supervisor/dbus/udisks2/const.py b/supervisor/dbus/udisks2/const.py index d45314941..45fa3de80 100644 --- a/supervisor/dbus/udisks2/const.py +++ b/supervisor/dbus/udisks2/const.py @@ -4,6 +4,8 @@ from enum import StrEnum from dbus_fast import Variant +from ..enum import DBusStrEnum + UDISKS2_DEFAULT_OPTIONS = {"auth.no_user_interaction": Variant("b", True)} @@ -31,7 +33,7 @@ class FormatType(StrEnum): GPT = "gpt" -class PartitionTableType(StrEnum): +class PartitionTableType(DBusStrEnum): """Partition Table type.""" DOS = "dos" diff --git a/supervisor/utils/sentry.py b/supervisor/utils/sentry.py index 3afcd2bd1..efc8c5c3f 100644 --- a/supervisor/utils/sentry.py +++ b/supervisor/utils/sentry.py @@ -3,6 +3,7 @@ import asyncio from functools import partial import logging +from typing import Literal from aiohttp.web_exceptions import HTTPBadGateway, HTTPServiceUnavailable import sentry_sdk @@ -78,6 +79,33 @@ async def async_capture_exception(err: BaseException) -> None: ) +def fire_and_forget_capture_message( + msg: str, + level: Literal["fatal", "critical", "error", "warning", "info", "debug"] + | None = "warning", +) -> None: + """Capture a message and send to sentry without blocking the event loop. + + Safe to call from sync code running in the event loop. The executor future + is intentionally not awaited (fire-and-forget). + """ + if not sentry_sdk.is_initialized(): + return + + def _capture() -> None: + try: + sentry_sdk.capture_message(msg, level=level) + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Failed to send message to Sentry: %s", msg) + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + _capture() + else: + loop.run_in_executor(None, _capture) + + def close_sentry() -> None: """Close the current sentry client. diff --git a/tests/dbus/network/test_interface.py b/tests/dbus/network/test_interface.py index ae4ad9c96..8e8432787 100644 --- a/tests/dbus/network/test_interface.py +++ b/tests/dbus/network/test_interface.py @@ -197,7 +197,8 @@ async def test_unknown_device_type( device_eth0_service.emit_properties_changed({"DeviceType": 1000}) await device_eth0_service.ping() - # Should return UNKNOWN instead of crashing - assert interface.type == DeviceType.UNKNOWN + # Should preserve the actual value as a pseudo-member instead of crashing + assert isinstance(interface.type, DeviceType) + assert interface.type == 1000 # Wireless should be None since it's not a wireless device assert interface.wireless is None diff --git a/tests/dbus/test_enum.py b/tests/dbus/test_enum.py new file mode 100644 index 000000000..4985dbc21 --- /dev/null +++ b/tests/dbus/test_enum.py @@ -0,0 +1,324 @@ +"""Tests for D-Bus tolerant enum base classes.""" + +import logging +from unittest.mock import patch + +import pytest + +from supervisor.dbus.const import ( + ConnectionState, + ConnectionType, + ConnectivityState, + DeviceType, + DNSOverTLSEnabled, + InterfaceMethod, + MulticastProtocolEnabled, + RaucState, + UnitActiveState, + WirelessMethodType, +) +from supervisor.dbus.enum import DBusIntEnum, DBusStrEnum, _reported +from supervisor.dbus.udisks2.const import PartitionTableType + + +@pytest.fixture(autouse=True) +def _clear_reported(): + """Clear the deduplication set between tests.""" + _reported.clear() + + +# -- Test fixtures: concrete subclasses for isolated testing -- + + +class SampleStrEnum(DBusStrEnum): + """Sample StrEnum for testing.""" + + ALPHA = "alpha" + BETA = "beta" + + +class SampleIntEnum(DBusIntEnum): + """Sample IntEnum for testing.""" + + ONE = 1 + TWO = 2 + + +# -- DBusStrEnum tests -- + + +def test_str_known_value(): + """Test known value returns the defined member.""" + assert SampleStrEnum("alpha") is SampleStrEnum.ALPHA + assert SampleStrEnum("beta") is SampleStrEnum.BETA + + +def test_str_unknown_value_returns_pseudo_member(caplog): + """Test unknown value creates a pseudo-member.""" + with caplog.at_level(logging.WARNING): + result = SampleStrEnum("gamma") + + assert isinstance(result, SampleStrEnum) + assert result.value == "gamma" + assert result.name == "gamma" + assert "Unknown SampleStrEnum value received from D-Bus: gamma" in caplog.text + + +def test_str_unknown_value_str(): + """Test unknown value behaves as str.""" + result = SampleStrEnum("gamma") + assert str(result) == "gamma" + assert result == "gamma" + + +def test_str_members_not_polluted(): + """Test pseudo-members don't appear in __members__ or list().""" + SampleStrEnum("gamma") + assert "gamma" not in SampleStrEnum.__members__ + assert set(SampleStrEnum) == {SampleStrEnum.ALPHA, SampleStrEnum.BETA} + + +def test_str_non_str_raises_value_error(): + """Test non-string values raise ValueError.""" + with pytest.raises(ValueError): + SampleStrEnum(123) + + +def test_str_hash_consistency(): + """Test pseudo-members hash like their string value.""" + result = SampleStrEnum("gamma") + assert hash(result) == hash("gamma") + assert {result: True}["gamma"] + + +def test_str_match_known(): + """Test match statement with known value.""" + val = SampleStrEnum("alpha") + match val: + case SampleStrEnum.ALPHA: + matched = "alpha" + case _: + matched = "default" + assert matched == "alpha" + + +def test_str_match_unknown_falls_to_default(): + """Test match statement with unknown value falls to default.""" + val = SampleStrEnum("gamma") + match val: + case SampleStrEnum.ALPHA: + matched = "alpha" + case SampleStrEnum.BETA: + matched = "beta" + case _: + matched = "default" + assert matched == "default" + + +# -- DBusIntEnum tests -- + + +def test_int_known_value(): + """Test known value returns the defined member.""" + assert SampleIntEnum(1) is SampleIntEnum.ONE + assert SampleIntEnum(2) is SampleIntEnum.TWO + + +def test_int_unknown_value_returns_pseudo_member(caplog): + """Test unknown value creates a pseudo-member.""" + with caplog.at_level(logging.WARNING): + result = SampleIntEnum(999) + + assert isinstance(result, SampleIntEnum) + assert result.value == 999 + assert result.name == "UNKNOWN_999" + assert "Unknown SampleIntEnum value received from D-Bus: 999" in caplog.text + + +def test_int_unknown_value_int(): + """Test unknown value behaves as int.""" + result = SampleIntEnum(999) + assert int(result) == 999 + assert result == 999 + + +def test_int_members_not_polluted(): + """Test pseudo-members don't appear in __members__ or list().""" + SampleIntEnum(999) + assert "UNKNOWN_999" not in SampleIntEnum.__members__ + assert set(SampleIntEnum) == {SampleIntEnum.ONE, SampleIntEnum.TWO} + + +def test_int_non_int_raises_value_error(): + """Test non-integer values raise ValueError.""" + with pytest.raises(ValueError): + SampleIntEnum("abc") + + +def test_int_hash_consistency(): + """Test pseudo-members hash like their int value.""" + result = SampleIntEnum(999) + assert hash(result) == hash(999) + assert {result: True}[999] + + +def test_int_match_known(): + """Test match statement with known value.""" + val = SampleIntEnum(1) + match val: + case SampleIntEnum.ONE: + matched = "one" + case _: + matched = "default" + assert matched == "one" + + +def test_int_match_unknown_falls_to_default(): + """Test match statement with unknown value falls to default.""" + val = SampleIntEnum(999) + match val: + case SampleIntEnum.ONE: + matched = "one" + case SampleIntEnum.TWO: + matched = "two" + case _: + matched = "default" + assert matched == "default" + + +# -- Integration tests with actual D-Bus enums -- + + +def test_device_type_unknown(): + """Test DeviceType handles unknown device types.""" + result = DeviceType(999) + assert isinstance(result, DeviceType) + assert result.value == 999 + assert result != DeviceType.UNKNOWN + + +def test_device_type_known(): + """Test DeviceType still works for known values.""" + assert DeviceType(1) is DeviceType.ETHERNET + assert DeviceType(2) is DeviceType.WIRELESS + + +def test_unit_active_state_unknown(): + """Test UnitActiveState handles unknown states.""" + result = UnitActiveState("refreshing") + assert isinstance(result, UnitActiveState) + assert result.value == "refreshing" + + +def test_unit_active_state_known(): + """Test UnitActiveState still works for known values.""" + assert UnitActiveState("active") is UnitActiveState.ACTIVE + + +def test_rauc_state_unknown(): + """Test RaucState handles unknown states.""" + result = RaucState("testing") + assert isinstance(result, RaucState) + assert result.value == "testing" + + +def test_connection_type_unknown(): + """Test ConnectionType handles unknown types.""" + result = ConnectionType("802-11-olpc-mesh") + assert isinstance(result, ConnectionType) + assert result.value == "802-11-olpc-mesh" + + +def test_connection_state_unknown(): + """Test ConnectionState handles unknown states.""" + result = ConnectionState(99) + assert isinstance(result, ConnectionState) + assert result.value == 99 + + +def test_connectivity_state_unknown(): + """Test ConnectivityState handles unknown states.""" + result = ConnectivityState(99) + assert isinstance(result, ConnectivityState) + assert result.value == 99 + + +def test_wireless_method_type_unknown(): + """Test WirelessMethodType handles unknown types.""" + result = WirelessMethodType(99) + assert isinstance(result, WirelessMethodType) + assert result.value == 99 + + +def test_interface_method_unknown(): + """Test InterfaceMethod handles unknown methods.""" + result = InterfaceMethod("shared") + assert isinstance(result, InterfaceMethod) + assert result.value == "shared" + + +def test_multicast_protocol_enabled_unknown(): + """Test MulticastProtocolEnabled handles unknown values.""" + result = MulticastProtocolEnabled("maybe") + assert isinstance(result, MulticastProtocolEnabled) + assert result.value == "maybe" + + +def test_dns_over_tls_enabled_unknown(): + """Test DNSOverTLSEnabled handles unknown values.""" + result = DNSOverTLSEnabled("strict") + assert isinstance(result, DNSOverTLSEnabled) + assert result.value == "strict" + + +def test_partition_table_type_unknown(): + """Test PartitionTableType handles unknown types.""" + result = PartitionTableType("mbr") + assert isinstance(result, PartitionTableType) + assert result.value == "mbr" + + +# -- Sentry reporting tests -- + + +@patch("supervisor.dbus.enum.fire_and_forget_capture_message") +def test_unknown_str_reports_to_sentry(mock_capture): + """Test unknown StrEnum value is reported to Sentry.""" + SampleStrEnum("delta") + mock_capture.assert_called_once_with( + "Unknown SampleStrEnum value received from D-Bus: delta" + ) + + +@patch("supervisor.dbus.enum.fire_and_forget_capture_message") +def test_unknown_int_reports_to_sentry(mock_capture): + """Test unknown IntEnum value is reported to Sentry.""" + SampleIntEnum(777) + mock_capture.assert_called_once_with( + "Unknown SampleIntEnum value received from D-Bus: 777" + ) + + +@patch("supervisor.dbus.enum.fire_and_forget_capture_message") +def test_duplicate_not_reported_twice(mock_capture): + """Test the same unknown value is only reported to Sentry once.""" + SampleIntEnum(888) + SampleIntEnum(888) + SampleIntEnum(888) + mock_capture.assert_called_once() + + +@patch("supervisor.dbus.enum.fire_and_forget_capture_message") +def test_different_values_each_reported(mock_capture): + """Test different unknown values are each reported separately.""" + SampleIntEnum(100) + SampleIntEnum(200) + assert mock_capture.call_count == 2 + + +@patch("supervisor.dbus.enum.fire_and_forget_capture_message") +def test_known_value_not_reported(mock_capture): + """Test known values don't trigger Sentry reports.""" + SampleStrEnum("alpha") + SampleIntEnum(1) + mock_capture.assert_not_called()