Compare commits

..

1 Commits

Author SHA1 Message Date
Stefan Agner
dbb4eab381 Handle update errors in automatic Supervisor update task
Wrap the Supervisor auto-update call with suppress(SupervisorUpdateError)
to prevent unhandled exceptions from propagating. When an automatic update
fails, errors are already logged by the exception handlers, and there's no
meaningful recovery action the scheduler task can take.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 15:32:44 +01:00
14 changed files with 21 additions and 137 deletions

View File

@@ -1,10 +1,13 @@
image: ghcr.io/home-assistant/{arch}-hassio-supervisor image: ghcr.io/home-assistant/{arch}-hassio-supervisor
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22-2025.11.1 aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22
armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22-2025.11.1 armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22-2025.11.1 armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22-2025.11.1 amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22
i386: ghcr.io/home-assistant/i386-base-python:3.13-alpine3.22-2025.11.1 i386: ghcr.io/home-assistant/i386-base-python:3.13-alpine3.22
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign: cosign:
base_identity: https://github.com/home-assistant/docker-base/.* base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.* identity: https://github.com/home-assistant/supervisor/.*

View File

@@ -306,8 +306,6 @@ class DeviceType(IntEnum):
VLAN = 11 VLAN = 11
TUN = 16 TUN = 16
VETH = 20 VETH = 20
WIREGUARD = 29
LOOPBACK = 32
class WirelessMethodType(IntEnum): class WirelessMethodType(IntEnum):

View File

@@ -134,10 +134,9 @@ class NetworkManager(DBusInterfaceProxy):
async def check_connectivity(self, *, force: bool = False) -> ConnectivityState: async def check_connectivity(self, *, force: bool = False) -> ConnectivityState:
"""Check the connectivity of the host.""" """Check the connectivity of the host."""
if force: if force:
return ConnectivityState( return await self.connected_dbus.call("check_connectivity")
await self.connected_dbus.call("check_connectivity") else:
) return await self.connected_dbus.get("connectivity")
return ConnectivityState(await self.connected_dbus.get("connectivity"))
async def connect(self, bus: MessageBus) -> None: async def connect(self, bus: MessageBus) -> None:
"""Connect to system's D-Bus.""" """Connect to system's D-Bus."""

View File

@@ -69,7 +69,7 @@ class NetworkConnection(DBusInterfaceProxy):
@dbus_property @dbus_property
def state(self) -> ConnectionStateType: def state(self) -> ConnectionStateType:
"""Return the state of the connection.""" """Return the state of the connection."""
return ConnectionStateType(self.properties[DBUS_ATTR_STATE]) return self.properties[DBUS_ATTR_STATE]
@property @property
def state_flags(self) -> set[ConnectionStateFlags]: def state_flags(self) -> set[ConnectionStateFlags]:

View File

@@ -1,6 +1,5 @@
"""NetworkInterface object for Network Manager.""" """NetworkInterface object for Network Manager."""
import logging
from typing import Any from typing import Any
from dbus_fast.aio.message_bus import MessageBus from dbus_fast.aio.message_bus import MessageBus
@@ -24,8 +23,6 @@ from .connection import NetworkConnection
from .setting import NetworkSetting from .setting import NetworkSetting
from .wireless import NetworkWireless from .wireless import NetworkWireless
_LOGGER: logging.Logger = logging.getLogger(__name__)
class NetworkInterface(DBusInterfaceProxy): class NetworkInterface(DBusInterfaceProxy):
"""NetworkInterface object represents Network Manager Device objects. """NetworkInterface object represents Network Manager Device objects.
@@ -60,15 +57,7 @@ class NetworkInterface(DBusInterfaceProxy):
@dbus_property @dbus_property
def type(self) -> DeviceType: def type(self) -> DeviceType:
"""Return interface type.""" """Return interface type."""
try: return self.properties[DBUS_ATTR_DEVICE_TYPE]
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
@property @property
@dbus_property @dbus_property

View File

@@ -34,7 +34,6 @@ class JobCondition(StrEnum):
PLUGINS_UPDATED = "plugins_updated" PLUGINS_UPDATED = "plugins_updated"
RUNNING = "running" RUNNING = "running"
SUPERVISOR_UPDATED = "supervisor_updated" SUPERVISOR_UPDATED = "supervisor_updated"
ARCHITECTURE_SUPPORTED = "architecture_supported"
class JobConcurrency(StrEnum): class JobConcurrency(StrEnum):

View File

@@ -441,14 +441,6 @@ class Job(CoreSysAttributes):
raise JobConditionException( raise JobConditionException(
f"'{method_name}' blocked from execution, supervisor needs to be updated first" f"'{method_name}' blocked from execution, supervisor needs to be updated first"
) )
if (
JobCondition.ARCHITECTURE_SUPPORTED in used_conditions
and UnsupportedReason.SYSTEM_ARCHITECTURE
in coresys.sys_resolution.unsupported
):
raise JobConditionException(
f"'{method_name}' blocked from execution, unsupported system architecture"
)
if JobCondition.PLUGINS_UPDATED in used_conditions and ( if JobCondition.PLUGINS_UPDATED in used_conditions and (
out_of_date := [ out_of_date := [

View File

@@ -1,5 +1,6 @@
"""A collection of tasks.""" """A collection of tasks."""
from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import cast from typing import cast
@@ -13,6 +14,7 @@ from ..exceptions import (
BackupFileNotFoundError, BackupFileNotFoundError,
HomeAssistantError, HomeAssistantError,
ObserverError, ObserverError,
SupervisorUpdateError,
) )
from ..homeassistant.const import LANDINGPAGE, WSType from ..homeassistant.const import LANDINGPAGE, WSType
from ..jobs.const import JobConcurrency from ..jobs.const import JobConcurrency
@@ -161,7 +163,6 @@ class Tasks(CoreSysAttributes):
JobCondition.INTERNET_HOST, JobCondition.INTERNET_HOST,
JobCondition.OS_SUPPORTED, JobCondition.OS_SUPPORTED,
JobCondition.RUNNING, JobCondition.RUNNING,
JobCondition.ARCHITECTURE_SUPPORTED,
], ],
concurrency=JobConcurrency.REJECT, concurrency=JobConcurrency.REJECT,
) )
@@ -174,7 +175,11 @@ class Tasks(CoreSysAttributes):
"Found new Supervisor version %s, updating", "Found new Supervisor version %s, updating",
self.sys_supervisor.latest_version, self.sys_supervisor.latest_version,
) )
await self.sys_supervisor.update()
# Errors are logged by the exceptions, we can't really do something
# if an update fails here.
with suppress(SupervisorUpdateError):
await self.sys_supervisor.update()
async def _watchdog_homeassistant_api(self): async def _watchdog_homeassistant_api(self):
"""Create scheduler task for monitoring running state of API. """Create scheduler task for monitoring running state of API.

View File

@@ -23,5 +23,4 @@ PLUGIN_UPDATE_CONDITIONS = [
JobCondition.HEALTHY, JobCondition.HEALTHY,
JobCondition.INTERNET_HOST, JobCondition.INTERNET_HOST,
JobCondition.SUPERVISOR_UPDATED, JobCondition.SUPERVISOR_UPDATED,
JobCondition.ARCHITECTURE_SUPPORTED,
] ]

View File

@@ -58,7 +58,6 @@ class UnsupportedReason(StrEnum):
SYSTEMD_JOURNAL = "systemd_journal" SYSTEMD_JOURNAL = "systemd_journal"
SYSTEMD_RESOLVED = "systemd_resolved" SYSTEMD_RESOLVED = "systemd_resolved"
VIRTUALIZATION_IMAGE = "virtualization_image" VIRTUALIZATION_IMAGE = "virtualization_image"
SYSTEM_ARCHITECTURE = "system_architecture"
class UnhealthyReason(StrEnum): class UnhealthyReason(StrEnum):

View File

@@ -1,38 +0,0 @@
"""Evaluation class for system architecture support."""
from ...const import CoreState
from ...coresys import CoreSys
from ..const import UnsupportedReason
from .base import EvaluateBase
def setup(coresys: CoreSys) -> EvaluateBase:
"""Initialize evaluation-setup function."""
return EvaluateSystemArchitecture(coresys)
class EvaluateSystemArchitecture(EvaluateBase):
"""Evaluate if the current Supervisor architecture is supported."""
@property
def reason(self) -> UnsupportedReason:
"""Return a UnsupportedReason enum."""
return UnsupportedReason.SYSTEM_ARCHITECTURE
@property
def on_failure(self) -> str:
"""Return a string that is printed when self.evaluate is True."""
return "System architecture is no longer supported. Move to a supported system architecture."
@property
def states(self) -> list[CoreState]:
"""Return a list of valid states when this evaluation can run."""
return [CoreState.INITIALIZE]
async def evaluate(self):
"""Run evaluation."""
return self.sys_host.info.sys_arch.supervisor in {
"i386",
"armhf",
"armv7",
}

View File

@@ -242,10 +242,9 @@ class Updater(FileConfiguration, CoreSysAttributes):
@Job( @Job(
name="updater_fetch_data", name="updater_fetch_data",
conditions=[ conditions=[
JobCondition.ARCHITECTURE_SUPPORTED,
JobCondition.INTERNET_SYSTEM, JobCondition.INTERNET_SYSTEM,
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
JobCondition.OS_SUPPORTED, JobCondition.OS_SUPPORTED,
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
], ],
on_condition=UpdaterJobError, on_condition=UpdaterJobError,
throttle_period=timedelta(seconds=30), throttle_period=timedelta(seconds=30),

View File

@@ -184,20 +184,3 @@ async def test_interface_becomes_unmanaged(
assert wireless.is_connected is False assert wireless.is_connected is False
assert eth0.connection is None assert eth0.connection is None
assert connection.is_connected is False assert connection.is_connected is False
async def test_unknown_device_type(
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
):
"""Test unknown device types are handled gracefully."""
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
await interface.connect(dbus_session_bus)
# Emit an unknown device type (e.g., 1000 which doesn't exist in the enum)
device_eth0_service.emit_properties_changed({"DeviceType": 1000})
await device_eth0_service.ping()
# Should return UNKNOWN instead of crashing
assert interface.type == DeviceType.UNKNOWN
# Wireless should be None since it's not a wireless device
assert interface.wireless is None

View File

@@ -1,43 +0,0 @@
"""Test evaluation supported system architectures."""
from unittest.mock import PropertyMock, patch
import pytest
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.resolution.evaluations.system_architecture import (
EvaluateSystemArchitecture,
)
@pytest.mark.parametrize("arch", ["i386", "armhf", "armv7"])
async def test_evaluation_unsupported_architectures(
coresys: CoreSys,
arch: str,
):
"""Test evaluation of unsupported system architectures."""
system_architecture = EvaluateSystemArchitecture(coresys)
await coresys.core.set_state(CoreState.INITIALIZE)
with patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value=arch)
):
await system_architecture()
assert system_architecture.reason in coresys.resolution.unsupported
@pytest.mark.parametrize("arch", ["amd64", "aarch64"])
async def test_evaluation_supported_architectures(
coresys: CoreSys,
arch: str,
):
"""Test evaluation of supported system architectures."""
system_architecture = EvaluateSystemArchitecture(coresys)
await coresys.core.set_state(CoreState.INITIALIZE)
with patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value=arch)
):
await system_architecture()
assert system_architecture.reason not in coresys.resolution.unsupported