diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index 51b4676e8f8..9a0d4af1d19 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -24,6 +24,7 @@ class JobCondition(StrEnum): FROZEN = "frozen" HAOS = "haos" HEALTHY = "healthy" + HOME_ASSISTANT_CORE_SUPPORTED = "home_assistant_core_supported" HOST_NETWORK = "host_network" INTERNET_HOST = "internet_host" INTERNET_SYSTEM = "internet_system" diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index c5997aca195..0fc23fdedaf 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -404,6 +404,15 @@ async def check_conditions( f"'{method_name}' blocked from execution, unsupported OS version" ) + if ( + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED in used_conditions + and UnsupportedReason.HOME_ASSISTANT_CORE_VERSION + in coresys.sys_resolution.unsupported + ): + raise JobConditionException( + f"'{method_name}' blocked from execution, unsupported Home Assistant Core 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 9ca5a4d65e9..27c5e0e1348 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -358,7 +358,11 @@ async def _watchdog_addon_application(self): @Job( name="tasks_reload_store", - conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED], + conditions=[ + JobCondition.SUPERVISOR_UPDATED, + JobCondition.OS_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, + ], ) async def _reload_store(self) -> None: """Reload store and check for addon updates.""" diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index a2fb3a97f2b..6ce87a7e9a3 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -44,6 +44,7 @@ class UnsupportedReason(StrEnum): DNS_SERVER = "dns_server" DOCKER_CONFIGURATION = "docker_configuration" DOCKER_VERSION = "docker_version" + HOME_ASSISTANT_CORE_VERSION = "home_assistant_core_version" JOB_CONDITIONS = "job_conditions" LXC = "lxc" NETWORK_MANAGER = "network_manager" diff --git a/supervisor/resolution/evaluations/home_assistant_core_version.py b/supervisor/resolution/evaluations/home_assistant_core_version.py new file mode 100644 index 00000000000..9cb873aff6e --- /dev/null +++ b/supervisor/resolution/evaluations/home_assistant_core_version.py @@ -0,0 +1,93 @@ +"""Evaluation class for Core version.""" + +import logging + +from awesomeversion import ( + AwesomeVersion, + AwesomeVersionException, + AwesomeVersionStrategy, +) + +from ...const import CoreState +from ...coresys import CoreSys +from ...homeassistant.const import LANDINGPAGE +from ..const import UnsupportedReason +from .base import EvaluateBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> EvaluateBase: + """Initialize evaluation-setup function.""" + return EvaluateHomeAssistantCoreVersion(coresys) + + +class EvaluateHomeAssistantCoreVersion(EvaluateBase): + """Evaluate the Home Assistant Core version.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.HOME_ASSISTANT_CORE_VERSION + + @property + def on_failure(self) -> str: + """Return a string that is printed when self.evaluate is True.""" + return f"Home Assistant Core version '{self.sys_homeassistant.version}' is more than 2 years old!" + + @property + def states(self) -> list[CoreState]: + """Return a list of valid states when this evaluation can run.""" + return [CoreState.RUNNING, CoreState.SETUP] + + async def evaluate(self) -> bool: + """Run evaluation.""" + if not (current := self.sys_homeassistant.version) or not ( + latest := self.sys_homeassistant.latest_version + ): + return False + + # Skip evaluation for landingpage version + if current == LANDINGPAGE: + return False + + try: + # We use the latest known version as reference instead of current date. + # This is crucial because when update information refresh is disabled due to + # unsupported Core version, using date would create a permanent unsupported state. + # Even if the user updates to the last known version, the system would remain + # unsupported in 4+ years. By using latest known version, updating Core to the + # last known version makes the system supported again, allowing update refresh. + # + # Home Assistant uses CalVer versioning (2024.1, 2024.2, etc.) with monthly releases. + # We consider versions more than 2 years behind as unsupported. + if ( + latest.strategy != AwesomeVersionStrategy.CALVER + or latest.year is None + or latest.minor is None + ): + return True # Invalid latest version format + + # Calculate 24 months back from latest version + cutoff_month = int(latest.minor) + cutoff_year = int(latest.year) - 2 + + # Create cutoff version + cutoff_version = AwesomeVersion( + f"{cutoff_year}.{cutoff_month}", + ensure_strategy=AwesomeVersionStrategy.CALVER, + ) + + # Compare current version with the cutoff + return current < cutoff_version + + except (AwesomeVersionException, ValueError, IndexError) as err: + # This is run regularly, avoid log spam by logging at debug level + _LOGGER.debug( + "Failed to parse Home Assistant version '%s' or latest version '%s': %s", + current, + latest, + err, + ) + # Consider non-parseable versions as unsupported + return True diff --git a/supervisor/resolution/evaluations/os_version.py b/supervisor/resolution/evaluations/os_version.py index 7aa99379ffe..84976110d83 100644 --- a/supervisor/resolution/evaluations/os_version.py +++ b/supervisor/resolution/evaluations/os_version.py @@ -1,5 +1,7 @@ """Evaluation class for OS version.""" +import logging + from awesomeversion import AwesomeVersion, AwesomeVersionException from ...const import CoreState @@ -7,6 +9,8 @@ from ..const import UnsupportedReason from .base import EvaluateBase +_LOGGER: logging.Logger = logging.getLogger(__name__) + def setup(coresys: CoreSys) -> EvaluateBase: """Initialize evaluation-setup function.""" @@ -47,5 +51,13 @@ async def evaluate(self) -> bool: last_supported_version = AwesomeVersion(f"{int(latest.major) - 4}.0") try: return current < last_supported_version - except AwesomeVersionException: + except AwesomeVersionException as err: + # This is run regularly, avoid log spam by logging at debug level + _LOGGER.debug( + "Can't parse OS version '%s' or latest version '%s': %s", + current, + latest, + err, + ) + # Consider non-parseable versions as unsupported return True diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index c1fe79f22ee..c8529f4775e 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -74,7 +74,11 @@ async def load(self) -> None: @Job( name="store_manager_reload", - conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED], + conditions=[ + JobCondition.SUPERVISOR_UPDATED, + JobCondition.OS_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, + ], on_condition=StoreJobError, ) async def reload(self, repository: Repository | None = None) -> None: @@ -117,6 +121,7 @@ async def reload(self, repository: Repository | None = None) -> None: JobCondition.INTERNET_SYSTEM, JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, ], on_condition=StoreJobError, ) diff --git a/supervisor/updater.py b/supervisor/updater.py index 32932a7df83..a4b837e46e8 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -247,7 +247,11 @@ async def _check_connectivity(self, connectivity: bool): @Job( name="updater_fetch_data", - conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.OS_SUPPORTED], + conditions=[ + JobCondition.INTERNET_SYSTEM, + JobCondition.OS_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, + ], on_condition=UpdaterJobError, throttle_period=timedelta(seconds=30), concurrency=JobConcurrency.QUEUE, diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 9d20b1c0d37..1c61eef9685 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -25,7 +25,7 @@ from supervisor.jobs.job_group import JobGroup from supervisor.os.manager import OSManager from supervisor.plugins.audio import PluginAudio -from supervisor.resolution.const import UnhealthyReason +from supervisor.resolution.const import UnhealthyReason, UnsupportedReason from supervisor.supervisor import Supervisor from supervisor.utils.dt import utcnow @@ -1384,3 +1384,34 @@ async def nested_method(self) -> None: assert test.call_count == 2 # Should execute now assert test.nested_call_count == 2 # Nested call should also execute + + +async def test_core_supported(coresys: CoreSys, caplog: pytest.LogCaptureFixture): + """Test the core_supported decorator.""" + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + + @Job( + name="test_core_supported_execute", + conditions=[JobCondition.HOME_ASSISTANT_CORE_SUPPORTED], + ) + async def execute(self): + """Execute the class method.""" + return True + + test = TestClass(coresys) + assert await test.execute() + + coresys.resolution.unsupported.append(UnsupportedReason.HOME_ASSISTANT_CORE_VERSION) + assert not await test.execute() + assert ( + "blocked from execution, unsupported Home Assistant Core version" in caplog.text + ) + + coresys.jobs.ignore_conditions = [JobCondition.HOME_ASSISTANT_CORE_SUPPORTED] + assert await test.execute() diff --git a/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py b/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py new file mode 100644 index 00000000000..6171dcb2290 --- /dev/null +++ b/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py @@ -0,0 +1,154 @@ +"""Test Core Version evaluation.""" + +from unittest.mock import PropertyMock, patch + +from awesomeversion import AwesomeVersion +import pytest + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.homeassistant.const import LANDINGPAGE +from supervisor.homeassistant.module import HomeAssistant +from supervisor.resolution.evaluations.home_assistant_core_version import ( + EvaluateHomeAssistantCoreVersion, +) + + +@pytest.mark.parametrize( + "current,latest,expected", + [ + ("2022.1.0", "2024.12.0", True), # More than 24 months behind, unsupported + ("2023.1.0", "2024.12.0", False), # Less than 24 months behind, supported + ("2024.1.0", "2024.12.0", False), # Recent version, supported + ("2024.12.0", "2024.12.0", False), # Same as latest, supported + ("2024.11.0", "2024.12.0", False), # 1 month behind, supported + ( + "2022.12.0", + "2024.12.0", + False, + ), # Exactly 24 months behind, supported (boundary) + ("2022.11.0", "2024.12.0", True), # More than 24 months behind, unsupported + ("2021.6.0", "2024.12.0", True), # Very old version, unsupported + ("0.116.4", "2024.12.0", True), # Old version scheme, should be unsupported + ("0.118.1", "2024.12.0", True), # Old version scheme, should be unsupported + ("landingpage", "2024.12.0", False), # Landingpage version, should be supported + (None, "2024.12.0", False), # No current version info, check skipped + ("2024.1.0", None, False), # No latest version info, check skipped + ], +) +async def test_core_version_evaluation( + coresys: CoreSys, current: str | None, latest: str | None, expected: bool +): + """Test evaluation logic on Core versions.""" + evaluation = EvaluateHomeAssistantCoreVersion(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + with ( + patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=current and AwesomeVersion(current)), + ), + patch.object( + HomeAssistant, + "latest_version", + new=PropertyMock(return_value=latest and AwesomeVersion(latest)), + ), + ): + assert evaluation.reason not in coresys.resolution.unsupported + await evaluation() + assert (evaluation.reason in coresys.resolution.unsupported) is expected + + +async def test_core_version_evaluation_no_latest(coresys: CoreSys): + """Test evaluation when no latest version is available.""" + evaluation = EvaluateHomeAssistantCoreVersion(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + with ( + patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2022.1.0")), + ), + patch.object( + HomeAssistant, + "latest_version", + new=PropertyMock(return_value=None), + ), + ): + assert evaluation.reason not in coresys.resolution.unsupported + await evaluation() + # Without latest version info, evaluation should be skipped (not run) + assert evaluation.reason not in coresys.resolution.unsupported + + +async def test_core_version_invalid_format(coresys: CoreSys): + """Test evaluation with invalid version format.""" + evaluation = EvaluateHomeAssistantCoreVersion(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + with ( + patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("invalid.version")), + ), + patch.object( + HomeAssistant, + "latest_version", + new=PropertyMock(return_value=AwesomeVersion("2024.12.0")), + ), + ): + assert evaluation.reason not in coresys.resolution.unsupported + await evaluation() + # Invalid/non-parseable versions should be marked as unsupported + assert evaluation.reason in coresys.resolution.unsupported + + +async def test_core_version_landingpage(coresys: CoreSys): + """Test evaluation with landingpage version.""" + evaluation = EvaluateHomeAssistantCoreVersion(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + with ( + patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=LANDINGPAGE), + ), + patch.object( + HomeAssistant, + "latest_version", + new=PropertyMock(return_value=AwesomeVersion("2024.12.0")), + ), + ): + assert evaluation.reason not in coresys.resolution.unsupported + await evaluation() + # Landingpage should never be marked as unsupported + assert evaluation.reason not in coresys.resolution.unsupported + + +async def test_did_run(coresys: CoreSys): + """Test that the evaluation ran as expected.""" + evaluation = EvaluateHomeAssistantCoreVersion(coresys) + should_run = evaluation.states + should_not_run = [state for state in CoreState if state not in should_run] + assert len(should_run) != 0 + assert len(should_not_run) != 0 + + with patch( + "supervisor.resolution.evaluations.home_assistant_core_version.EvaluateHomeAssistantCoreVersion.evaluate", + return_value=None, + ) as evaluate: + for state in should_run: + await coresys.core.set_state(state) + await evaluation() + evaluate.assert_called_once() + evaluate.reset_mock() + + for state in should_not_run: + await coresys.core.set_state(state) + await evaluation() + evaluate.assert_not_called() + evaluate.reset_mock()