mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-11-14 05:20:21 +00:00
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 <michael.degatano@gmail.com> * Add debug message when we delay version fetch --------- Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user