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
19 changed files with 41 additions and 144 deletions

View File

@@ -68,7 +68,7 @@ jobs:
run: |
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
- name: Create PR
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
branch: autoupdate-frontend

View File

@@ -1,10 +1,13 @@
image: ghcr.io/home-assistant/{arch}-hassio-supervisor
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22-2025.11.1
armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22-2025.11.1
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22-2025.11.1
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22-2025.11.1
i386: ghcr.io/home-assistant/i386-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
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22
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:
base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.*

View File

@@ -4,7 +4,7 @@ aiohttp==3.13.2
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
awesomeversion==25.8.0
backports.zstd==1.1.0
backports.zstd==1.0.0
blockbuster==1.5.25
brotli==1.2.0
ciso8601==2.3.3

View File

@@ -1,15 +1,15 @@
astroid==4.0.2
coverage==7.12.0
mypy==1.18.2
pre-commit==4.5.0
pre-commit==4.4.0
pylint==4.0.3
pytest-aiohttp==1.1.0
pytest-asyncio==1.3.0
pytest-cov==7.0.0
pytest-timeout==2.4.0
pytest==9.0.1
ruff==0.14.6
time-machine==3.1.0
ruff==0.14.5
time-machine==3.0.0
types-docker==7.1.0.20251009
types-pyyaml==6.0.12.20250915
types-requests==2.32.4.20250913

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -441,14 +441,6 @@ class Job(CoreSysAttributes):
raise JobConditionException(
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 (
out_of_date := [

View File

@@ -1,5 +1,6 @@
"""A collection of tasks."""
from contextlib import suppress
from datetime import datetime, timedelta
import logging
from typing import cast
@@ -13,6 +14,7 @@ from ..exceptions import (
BackupFileNotFoundError,
HomeAssistantError,
ObserverError,
SupervisorUpdateError,
)
from ..homeassistant.const import LANDINGPAGE, WSType
from ..jobs.const import JobConcurrency
@@ -161,7 +163,6 @@ class Tasks(CoreSysAttributes):
JobCondition.INTERNET_HOST,
JobCondition.OS_SUPPORTED,
JobCondition.RUNNING,
JobCondition.ARCHITECTURE_SUPPORTED,
],
concurrency=JobConcurrency.REJECT,
)
@@ -174,7 +175,11 @@ class Tasks(CoreSysAttributes):
"Found new Supervisor version %s, updating",
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):
"""Create scheduler task for monitoring running state of API.

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ from ...coresys import CoreSys
from ..const import UnsupportedReason
from .base import EvaluateBase
SUPPORTED_OS = ["Debian GNU/Linux 12 (bookworm)"]
def setup(coresys: CoreSys) -> EvaluateBase:
"""Initialize evaluation-setup function."""
@@ -31,4 +33,6 @@ class EvaluateOperatingSystem(EvaluateBase):
async def evaluate(self) -> bool:
"""Run evaluation."""
return not self.sys_os.available
if self.sys_os.available:
return False
return self.sys_host.info.operating_system not in SUPPORTED_OS

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(
name="updater_fetch_data",
conditions=[
JobCondition.ARCHITECTURE_SUPPORTED,
JobCondition.INTERNET_SYSTEM,
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
JobCondition.OS_SUPPORTED,
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
],
on_condition=UpdaterJobError,
throttle_period=timedelta(seconds=30),

View File

@@ -184,20 +184,3 @@ async def test_interface_becomes_unmanaged(
assert wireless.is_connected is False
assert eth0.connection is None
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

@@ -5,7 +5,10 @@ from unittest.mock import MagicMock, patch
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.resolution.evaluations.operating_system import EvaluateOperatingSystem
from supervisor.resolution.evaluations.operating_system import (
SUPPORTED_OS,
EvaluateOperatingSystem,
)
async def test_evaluation(coresys: CoreSys):
@@ -26,6 +29,12 @@ async def test_evaluation(coresys: CoreSys):
assert operating_system.reason not in coresys.resolution.unsupported
coresys.os._available = False
coresys.host._info = MagicMock(
operating_system=SUPPORTED_OS[0], timezone=None, timezone_tzinfo=None
)
await operating_system()
assert operating_system.reason not in coresys.resolution.unsupported
async def test_did_run(coresys: CoreSys):
"""Test that the evaluation ran as expected."""

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