diff --git a/supervisor/core.py b/supervisor/core.py index bb869b015..41507f7af 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -231,6 +231,9 @@ class Core(CoreSysAttributes): # Mark booted partition as healthy await self.sys_os.mark_healthy() + # Refresh update information + await self.sys_updater.reload() + # On release channel, try update itself if auto update enabled if self.sys_supervisor.need_update and self.sys_updater.auto_update: if not self.healthy: @@ -301,7 +304,6 @@ class Core(CoreSysAttributes): # Upate Host/Deivce information self.sys_create_task(self.sys_host.reload()) - self.sys_create_task(self.sys_updater.reload()) self.sys_create_task(self.sys_resolution.healthcheck()) await self.set_state(CoreState.RUNNING) diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index 88d061a45..51b4676e8 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -29,6 +29,7 @@ class JobCondition(StrEnum): INTERNET_SYSTEM = "internet_system" MOUNT_AVAILABLE = "mount_available" OS_AGENT = "os_agent" + OS_SUPPORTED = "os_supported" PLUGINS_UPDATED = "plugins_updated" RUNNING = "running" SUPERVISOR_UPDATED = "supervisor_updated" diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index f66e0c648..c5997aca1 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -17,7 +17,12 @@ from ..exceptions import ( JobGroupExecutionLimitExceeded, ) from ..host.const import HostFeature -from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType +from ..resolution.const import ( + MINIMUM_FREE_SPACE_THRESHOLD, + ContextType, + IssueType, + UnsupportedReason, +) from ..utils.sentry import async_capture_exception from . import SupervisorJob from .const import JobConcurrency, JobCondition, JobThrottle @@ -391,6 +396,14 @@ class Job(CoreSysAttributes): f"'{method_name}' blocked from execution, no Home Assistant OS-Agent available" ) + if ( + JobCondition.OS_SUPPORTED in used_conditions + and UnsupportedReason.OS_VERSION in coresys.sys_resolution.unsupported + ): + raise JobConditionException( + f"'{method_name}' blocked from execution, unsupported OS version" + ) + if ( JobCondition.HOST_NETWORK in used_conditions and not coresys.sys_dbus.network.is_connected diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 4d05443cc..9ca5a4d65 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -159,6 +159,7 @@ class Tasks(CoreSysAttributes): JobCondition.FREE_SPACE, JobCondition.HEALTHY, JobCondition.INTERNET_HOST, + JobCondition.OS_SUPPORTED, JobCondition.RUNNING, ], concurrency=JobConcurrency.REJECT, @@ -355,7 +356,10 @@ class Tasks(CoreSysAttributes): finally: self._cache[addon.slug] = 0 - @Job(name="tasks_reload_store", conditions=[JobCondition.SUPERVISOR_UPDATED]) + @Job( + name="tasks_reload_store", + conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED], + ) async def _reload_store(self) -> None: """Reload store and check for addon updates.""" await self.sys_store.reload() diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index bb40c2f67..0369a8a22 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -251,6 +251,7 @@ class OSManager(CoreSysAttributes): self._version = AwesomeVersion(cpe.get_version()[0]) self._board = cpe.get_target_hardware()[0] self._os_name = cpe.get_product()[0] + await self.reload() await self.datadisk.load() diff --git a/supervisor/resolution/evaluations/os_version.py b/supervisor/resolution/evaluations/os_version.py index 0e4097436..7aa99379f 100644 --- a/supervisor/resolution/evaluations/os_version.py +++ b/supervisor/resolution/evaluations/os_version.py @@ -31,7 +31,7 @@ class EvaluateOSVersion(EvaluateBase): """Return a list of valid states when this evaluation can run.""" # Technically there's no reason to run this after STARTUP as update requires # a reboot. But if network is down we won't have latest version info then. - return [CoreState.RUNNING, CoreState.STARTUP] + return [CoreState.RUNNING, CoreState.SETUP] async def evaluate(self) -> bool: """Run evaluation.""" diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index 1f43bb561..c1fe79f22 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -74,7 +74,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration): @Job( name="store_manager_reload", - conditions=[JobCondition.SUPERVISOR_UPDATED], + conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED], on_condition=StoreJobError, ) async def reload(self, repository: Repository | None = None) -> None: @@ -113,7 +113,11 @@ class StoreManager(CoreSysAttributes, FileConfiguration): @Job( name="store_manager_add_repository", - conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.SUPERVISOR_UPDATED], + conditions=[ + JobCondition.INTERNET_SYSTEM, + JobCondition.SUPERVISOR_UPDATED, + JobCondition.OS_SUPPORTED, + ], on_condition=StoreJobError, ) async def add_repository(self, url: str, *, persist: bool = True) -> None: diff --git a/supervisor/updater.py b/supervisor/updater.py index 298dcb1a4..2b004c8a2 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -56,17 +56,27 @@ class Updater(FileConfiguration, CoreSysAttributes): async def load(self) -> None: """Update internal data.""" - # If there's no connectivity, delay initial version fetch - if not self.sys_supervisor.connectivity: - self._connectivity_listener = self.sys_bus.register_event( - BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, self._check_connectivity + # Delay loading data by default so JobCondition.OS_SUPPORTED works. + # Use HAOS unrestricted as indicator as this is what we need to evaluate + # if the operating system version is supported. + if self.sys_os.board and self.version_hassos_unrestricted is None: + _LOGGER.info( + "No OS update information found, force refreshing updater information" ) - return - - await self.reload() + await self.reload() async def reload(self) -> None: """Update internal data.""" + # If there's no connectivity, delay initial version fetch + if not self.sys_supervisor.connectivity: + _LOGGER.debug("No Supervisor connectivity, delaying version fetch") + if not self._connectivity_listener: + self._connectivity_listener = self.sys_bus.register_event( + BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, self._check_connectivity + ) + _LOGGER.info("No Supervisor connectivity, delaying version fetch") + return + with suppress(UpdaterError): await self.fetch_data() @@ -204,7 +214,7 @@ class Updater(FileConfiguration, CoreSysAttributes): @Job( name="updater_fetch_data", - conditions=[JobCondition.INTERNET_SYSTEM], + conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.OS_SUPPORTED], on_condition=UpdaterJobError, throttle_period=timedelta(seconds=30), concurrency=JobConcurrency.QUEUE, diff --git a/tests/plugins/test_plugin_manager.py b/tests/plugins/test_plugin_manager.py index 8cbee0c1c..2f5c63f9f 100644 --- a/tests/plugins/test_plugin_manager.py +++ b/tests/plugins/test_plugin_manager.py @@ -45,6 +45,7 @@ async def test_load( """Test plugin manager load.""" coresys.hardware.disk.get_disk_free_space = lambda x: 5000 await coresys.updater.load() + await coresys.updater.reload() need_update = PropertyMock(return_value=True) with ( diff --git a/tests/test_updater.py b/tests/test_updater.py index 750a12847..b1cf21bb7 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -7,10 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, patch from awesomeversion import AwesomeVersion import pytest -from supervisor.const import BusEvent +from supervisor.const import ATTR_HASSOS_UNRESTRICTED, BusEvent from supervisor.coresys import CoreSys from supervisor.dbus.const import ConnectivityState +from supervisor.exceptions import UpdaterJobError from supervisor.jobs import SupervisorJob +from supervisor.resolution.const import UnsupportedReason from tests.common import MockResponse, load_binary_fixture from tests.dbus_service_mocks.network_manager import ( @@ -122,6 +124,7 @@ async def test_delayed_fetch_for_connectivity( await coresys.host.network.check_connectivity() await coresys.updater.load() + await coresys.updater.reload() coresys.websession.get.assert_not_called() # Now signal host has connectivity and wait for fetch data to complete to assert @@ -138,3 +141,77 @@ async def test_delayed_fetch_for_connectivity( coresys.websession.get.call_args[0][0] == "https://version.home-assistant.io/stable.json" ) + + +@pytest.mark.usefixtures("no_job_throttle") +async def test_load_calls_reload_when_os_board_without_version( + coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock +) -> None: + """Test load calls reload when OS board exists but no version_hassos_unrestricted.""" + # Set up OS board but no version data + coresys.os._board = "rpi4" # pylint: disable=protected-access + coresys.security.force = True + + # Mock reload to verify it gets called + with patch.object(coresys.updater, "reload", new_callable=AsyncMock) as mock_reload: + await coresys.updater.load() + mock_reload.assert_called_once() + + +@pytest.mark.usefixtures("no_job_throttle") +async def test_load_skips_reload_when_os_board_with_version( + coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock +) -> None: + """Test load skips reload when OS board exists and version_hassos_unrestricted is set.""" + # Set up OS board and version data + coresys.os._board = "rpi4" # pylint: disable=protected-access + coresys.security.force = True + + # Pre-populate version_hassos_unrestricted by setting it directly on the data dict + # Use the same approach as other tests that modify internal state + coresys.updater._data[ATTR_HASSOS_UNRESTRICTED] = AwesomeVersion("13.1") # pylint: disable=protected-access + + # Mock reload to verify it doesn't get called + with patch.object(coresys.updater, "reload", new_callable=AsyncMock) as mock_reload: + await coresys.updater.load() + mock_reload.assert_not_called() + + +@pytest.mark.usefixtures("no_job_throttle") +async def test_load_skips_reload_when_no_os_board( + coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock +) -> None: + """Test load skips reload when no OS board is set.""" + # Ensure no OS board is set + coresys.os._board = None # pylint: disable=protected-access + + # Mock reload to verify it doesn't get called + with patch.object(coresys.updater, "reload", new_callable=AsyncMock) as mock_reload: + await coresys.updater.load() + mock_reload.assert_not_called() + + +async def test_fetch_data_no_update_when_os_unsupported( + coresys: CoreSys, websession: MagicMock +) -> None: + """Test that fetch_data doesn't update data when OS is unsupported.""" + # Store initial versions to compare later + initial_supervisor_version = coresys.updater.version_supervisor + initial_homeassistant_version = coresys.updater.version_homeassistant + initial_hassos_version = coresys.updater.version_hassos + + coresys.websession.head = AsyncMock() + + # Mark OS as unsupported by adding UnsupportedReason.OS_VERSION + coresys.resolution.unsupported.append(UnsupportedReason.OS_VERSION) + + # Attempt to fetch data should fail due to OS_SUPPORTED condition + with pytest.raises( + UpdaterJobError, match="blocked from execution, unsupported OS version" + ): + await coresys.updater.fetch_data() + + # Verify that versions were not updated + assert coresys.updater.version_supervisor == initial_supervisor_version + assert coresys.updater.version_homeassistant == initial_homeassistant_version + assert coresys.updater.version_hassos == initial_hassos_version