Skip to content

Commit 2d12920

Browse files
agnersmdegat01
andauthored
Stop refreshing the update information on outdated OS versions (#6098)
* Stop refreshing the update information on outdated OS versions Add `JobCondition.OS_SUPPORTED` to the updater job to avoid refreshing update information when the OS version is unsupported. This effectively freezes installations on unsupported OS versions and blocks Supervisor updates. Once deployed, this ensures that any Supervisor will always run on at least the minimum supported OS version. This requires to move the OS version check before Supervisor updater initialization to allow the `JobCondition.OS_SUPPORTED` to work correctly. * Run only OS version check in setup loads Instead of running a full system evaluation, only run the OS version check right after the OS manager is loaded. This allows the updater job condition to work correctly without running the full system evaluation, which is not needed at this point. * Prevent Core and Add-on updates on unsupported OS versions Also prevent Home Assistant Core and Add-on updates on unsupported OS versions. We could imply `JobCondition.SUPERVISOR_UPDATED` whenever OS is outdated, but this would also prevent the OS update itself. So we need this separate condition everywhere where `JobCondition.SUPERVISOR_UPDATED` is used except for OS updates. It should also be safe to let the add-on store update, we simply don't allow the add-on to be installed or updated if the OS is outdated. * Remove unnecessary Host info update It seems that the CPE information are already loaded in the HostInfo object. Remove the unnecessary update call. * Fix pytest * Delay refreshing of update data Delay refreshing of update data until after setup phase. This allows to use the JobCondition.OS_SUPPORTED safely. We still have to fetch the updater data in case OS information is outdated. This typically happens on device wipe. Note also that plug-ins will automatically refresh updater data in case it is missing the latest version information. This will reverse the order of updates when there are new plug-in and Supervisor update information available (e.g. on first startup): Previously the updater data got refreshed before the plug-in started, which caused them to update first. Then the Supervisor got update in startup phase. Now the updater data gets refreshed in startup phase, which then causes the Supervisor to update first before the plug-ins get updated after Supervisor restart. * Fix pytest * Fix updater tests * Add new tests to verify that updater reload is skipped * Fix pylint * Apply suggestions from code review Co-authored-by: Mike Degatano <[email protected]> * Add debug message when we delay version fetch --------- Co-authored-by: Mike Degatano <[email protected]>
1 parent 8a95113 commit 2d12920

File tree

10 files changed

+128
-15
lines changed

10 files changed

+128
-15
lines changed

supervisor/core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@ async def start(self) -> None:
231231
# Mark booted partition as healthy
232232
await self.sys_os.mark_healthy()
233233

234+
# Refresh update information
235+
await self.sys_updater.reload()
236+
234237
# On release channel, try update itself if auto update enabled
235238
if self.sys_supervisor.need_update and self.sys_updater.auto_update:
236239
if not self.healthy:
@@ -301,7 +304,6 @@ async def start(self) -> None:
301304

302305
# Upate Host/Deivce information
303306
self.sys_create_task(self.sys_host.reload())
304-
self.sys_create_task(self.sys_updater.reload())
305307
self.sys_create_task(self.sys_resolution.healthcheck())
306308

307309
await self.set_state(CoreState.RUNNING)

supervisor/jobs/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class JobCondition(StrEnum):
2929
INTERNET_SYSTEM = "internet_system"
3030
MOUNT_AVAILABLE = "mount_available"
3131
OS_AGENT = "os_agent"
32+
OS_SUPPORTED = "os_supported"
3233
PLUGINS_UPDATED = "plugins_updated"
3334
RUNNING = "running"
3435
SUPERVISOR_UPDATED = "supervisor_updated"

supervisor/jobs/decorator.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
JobGroupExecutionLimitExceeded,
1818
)
1919
from ..host.const import HostFeature
20-
from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType
20+
from ..resolution.const import (
21+
MINIMUM_FREE_SPACE_THRESHOLD,
22+
ContextType,
23+
IssueType,
24+
UnsupportedReason,
25+
)
2126
from ..utils.sentry import async_capture_exception
2227
from . import SupervisorJob
2328
from .const import JobConcurrency, JobCondition, JobThrottle
@@ -391,6 +396,14 @@ async def check_conditions(
391396
f"'{method_name}' blocked from execution, no Home Assistant OS-Agent available"
392397
)
393398

399+
if (
400+
JobCondition.OS_SUPPORTED in used_conditions
401+
and UnsupportedReason.OS_VERSION in coresys.sys_resolution.unsupported
402+
):
403+
raise JobConditionException(
404+
f"'{method_name}' blocked from execution, unsupported OS version"
405+
)
406+
394407
if (
395408
JobCondition.HOST_NETWORK in used_conditions
396409
and not coresys.sys_dbus.network.is_connected

supervisor/misc/tasks.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ async def _update_addons(self):
159159
JobCondition.FREE_SPACE,
160160
JobCondition.HEALTHY,
161161
JobCondition.INTERNET_HOST,
162+
JobCondition.OS_SUPPORTED,
162163
JobCondition.RUNNING,
163164
],
164165
concurrency=JobConcurrency.REJECT,
@@ -355,7 +356,10 @@ async def _watchdog_addon_application(self):
355356
finally:
356357
self._cache[addon.slug] = 0
357358

358-
@Job(name="tasks_reload_store", conditions=[JobCondition.SUPERVISOR_UPDATED])
359+
@Job(
360+
name="tasks_reload_store",
361+
conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED],
362+
)
359363
async def _reload_store(self) -> None:
360364
"""Reload store and check for addon updates."""
361365
await self.sys_store.reload()

supervisor/os/manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ async def load(self) -> None:
251251
self._version = AwesomeVersion(cpe.get_version()[0])
252252
self._board = cpe.get_target_hardware()[0]
253253
self._os_name = cpe.get_product()[0]
254+
254255
await self.reload()
255256

256257
await self.datadisk.load()

supervisor/resolution/evaluations/os_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def states(self) -> list[CoreState]:
3131
"""Return a list of valid states when this evaluation can run."""
3232
# Technically there's no reason to run this after STARTUP as update requires
3333
# a reboot. But if network is down we won't have latest version info then.
34-
return [CoreState.RUNNING, CoreState.STARTUP]
34+
return [CoreState.RUNNING, CoreState.SETUP]
3535

3636
async def evaluate(self) -> bool:
3737
"""Run evaluation."""

supervisor/store/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ async def load(self) -> None:
7474

7575
@Job(
7676
name="store_manager_reload",
77-
conditions=[JobCondition.SUPERVISOR_UPDATED],
77+
conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED],
7878
on_condition=StoreJobError,
7979
)
8080
async def reload(self, repository: Repository | None = None) -> None:
@@ -113,7 +113,11 @@ async def reload(self, repository: Repository | None = None) -> None:
113113

114114
@Job(
115115
name="store_manager_add_repository",
116-
conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.SUPERVISOR_UPDATED],
116+
conditions=[
117+
JobCondition.INTERNET_SYSTEM,
118+
JobCondition.SUPERVISOR_UPDATED,
119+
JobCondition.OS_SUPPORTED,
120+
],
117121
on_condition=StoreJobError,
118122
)
119123
async def add_repository(self, url: str, *, persist: bool = True) -> None:

supervisor/updater.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,27 @@ def __init__(self, coresys: CoreSys) -> None:
5656

5757
async def load(self) -> None:
5858
"""Update internal data."""
59-
# If there's no connectivity, delay initial version fetch
60-
if not self.sys_supervisor.connectivity:
61-
self._connectivity_listener = self.sys_bus.register_event(
62-
BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, self._check_connectivity
59+
# Delay loading data by default so JobCondition.OS_SUPPORTED works.
60+
# Use HAOS unrestricted as indicator as this is what we need to evaluate
61+
# if the operating system version is supported.
62+
if self.sys_os.board and self.version_hassos_unrestricted is None:
63+
_LOGGER.info(
64+
"No OS update information found, force refreshing updater information"
6365
)
64-
return
65-
66-
await self.reload()
66+
await self.reload()
6767

6868
async def reload(self) -> None:
6969
"""Update internal data."""
70+
# If there's no connectivity, delay initial version fetch
71+
if not self.sys_supervisor.connectivity:
72+
_LOGGER.debug("No Supervisor connectivity, delaying version fetch")
73+
if not self._connectivity_listener:
74+
self._connectivity_listener = self.sys_bus.register_event(
75+
BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, self._check_connectivity
76+
)
77+
_LOGGER.info("No Supervisor connectivity, delaying version fetch")
78+
return
79+
7080
with suppress(UpdaterError):
7181
await self.fetch_data()
7282

@@ -204,7 +214,7 @@ async def _check_connectivity(self, connectivity: bool):
204214

205215
@Job(
206216
name="updater_fetch_data",
207-
conditions=[JobCondition.INTERNET_SYSTEM],
217+
conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.OS_SUPPORTED],
208218
on_condition=UpdaterJobError,
209219
throttle_period=timedelta(seconds=30),
210220
concurrency=JobConcurrency.QUEUE,

tests/plugins/test_plugin_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ async def test_load(
4545
"""Test plugin manager load."""
4646
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
4747
await coresys.updater.load()
48+
await coresys.updater.reload()
4849

4950
need_update = PropertyMock(return_value=True)
5051
with (

tests/test_updater.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from awesomeversion import AwesomeVersion
88
import pytest
99

10-
from supervisor.const import BusEvent
10+
from supervisor.const import ATTR_HASSOS_UNRESTRICTED, BusEvent
1111
from supervisor.coresys import CoreSys
1212
from supervisor.dbus.const import ConnectivityState
13+
from supervisor.exceptions import UpdaterJobError
1314
from supervisor.jobs import SupervisorJob
15+
from supervisor.resolution.const import UnsupportedReason
1416

1517
from tests.common import MockResponse, load_binary_fixture
1618
from tests.dbus_service_mocks.network_manager import (
@@ -122,6 +124,7 @@ async def find_fetch_data_job_start(job: SupervisorJob):
122124
await coresys.host.network.check_connectivity()
123125

124126
await coresys.updater.load()
127+
await coresys.updater.reload()
125128
coresys.websession.get.assert_not_called()
126129

127130
# Now signal host has connectivity and wait for fetch data to complete to assert
@@ -138,3 +141,77 @@ async def find_fetch_data_job_start(job: SupervisorJob):
138141
coresys.websession.get.call_args[0][0]
139142
== "https://version.home-assistant.io/stable.json"
140143
)
144+
145+
146+
@pytest.mark.usefixtures("no_job_throttle")
147+
async def test_load_calls_reload_when_os_board_without_version(
148+
coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock
149+
) -> None:
150+
"""Test load calls reload when OS board exists but no version_hassos_unrestricted."""
151+
# Set up OS board but no version data
152+
coresys.os._board = "rpi4" # pylint: disable=protected-access
153+
coresys.security.force = True
154+
155+
# Mock reload to verify it gets called
156+
with patch.object(coresys.updater, "reload", new_callable=AsyncMock) as mock_reload:
157+
await coresys.updater.load()
158+
mock_reload.assert_called_once()
159+
160+
161+
@pytest.mark.usefixtures("no_job_throttle")
162+
async def test_load_skips_reload_when_os_board_with_version(
163+
coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock
164+
) -> None:
165+
"""Test load skips reload when OS board exists and version_hassos_unrestricted is set."""
166+
# Set up OS board and version data
167+
coresys.os._board = "rpi4" # pylint: disable=protected-access
168+
coresys.security.force = True
169+
170+
# Pre-populate version_hassos_unrestricted by setting it directly on the data dict
171+
# Use the same approach as other tests that modify internal state
172+
coresys.updater._data[ATTR_HASSOS_UNRESTRICTED] = AwesomeVersion("13.1") # pylint: disable=protected-access
173+
174+
# Mock reload to verify it doesn't get called
175+
with patch.object(coresys.updater, "reload", new_callable=AsyncMock) as mock_reload:
176+
await coresys.updater.load()
177+
mock_reload.assert_not_called()
178+
179+
180+
@pytest.mark.usefixtures("no_job_throttle")
181+
async def test_load_skips_reload_when_no_os_board(
182+
coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock
183+
) -> None:
184+
"""Test load skips reload when no OS board is set."""
185+
# Ensure no OS board is set
186+
coresys.os._board = None # pylint: disable=protected-access
187+
188+
# Mock reload to verify it doesn't get called
189+
with patch.object(coresys.updater, "reload", new_callable=AsyncMock) as mock_reload:
190+
await coresys.updater.load()
191+
mock_reload.assert_not_called()
192+
193+
194+
async def test_fetch_data_no_update_when_os_unsupported(
195+
coresys: CoreSys, websession: MagicMock
196+
) -> None:
197+
"""Test that fetch_data doesn't update data when OS is unsupported."""
198+
# Store initial versions to compare later
199+
initial_supervisor_version = coresys.updater.version_supervisor
200+
initial_homeassistant_version = coresys.updater.version_homeassistant
201+
initial_hassos_version = coresys.updater.version_hassos
202+
203+
coresys.websession.head = AsyncMock()
204+
205+
# Mark OS as unsupported by adding UnsupportedReason.OS_VERSION
206+
coresys.resolution.unsupported.append(UnsupportedReason.OS_VERSION)
207+
208+
# Attempt to fetch data should fail due to OS_SUPPORTED condition
209+
with pytest.raises(
210+
UpdaterJobError, match="blocked from execution, unsupported OS version"
211+
):
212+
await coresys.updater.fetch_data()
213+
214+
# Verify that versions were not updated
215+
assert coresys.updater.version_supervisor == initial_supervisor_version
216+
assert coresys.updater.version_homeassistant == initial_homeassistant_version
217+
assert coresys.updater.version_hassos == initial_hassos_version

0 commit comments

Comments
 (0)