From 86f70cceefc4b43b2138f187e522ef7f20c7eed7 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 24 Jul 2025 19:31:26 +0000 Subject: [PATCH] Add unsupported reason os_version and evaluation --- supervisor/resolution/const.py | 1 + .../resolution/evaluations/os_version.py | 51 ++++++++++++ .../evaluation/test_evaluate_os_version.py | 79 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 supervisor/resolution/evaluations/os_version.py create mode 100644 tests/resolution/evaluation/test_evaluate_os_version.py diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index f7c854e92..3d803ef57 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -58,6 +58,7 @@ class UnsupportedReason(StrEnum): SYSTEMD_JOURNAL = "systemd_journal" SYSTEMD_RESOLVED = "systemd_resolved" VIRTUALIZATION_IMAGE = "virtualization_image" + OS_VERSION = "os_version" class UnhealthyReason(StrEnum): diff --git a/supervisor/resolution/evaluations/os_version.py b/supervisor/resolution/evaluations/os_version.py new file mode 100644 index 000000000..d17215c2a --- /dev/null +++ b/supervisor/resolution/evaluations/os_version.py @@ -0,0 +1,51 @@ +"""Evaluation class for OS version.""" + +from awesomeversion import AwesomeVersion, AwesomeVersionException + +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 EvaluateOSVersion(coresys) + + +class EvaluateOSVersion(EvaluateBase): + """Evaluate the OS version.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.OS_VERSION + + @property + def on_failure(self) -> str: + """Return a string that is printed when self.evaluate is True.""" + return f"OS version '{self.sys_os.version}' is more than 4 versions behind the latest '{self.sys_os.latest_version}'!" + + @property + def states(self) -> list[CoreState]: + """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] + + async def evaluate(self) -> bool: + """Run evaluation.""" + if ( + not self.sys_os.available + or not (current := self.sys_os.version) + or not (latest := self.sys_os.latest_version) + or not latest.major + ): + return False + + # If current is more than 4 major versions behind latest, mark as unsupported + last_supported_version = AwesomeVersion(f"{int(latest.major) - 4}.0") + try: + return current < last_supported_version + except AwesomeVersionException: + return False diff --git a/tests/resolution/evaluation/test_evaluate_os_version.py b/tests/resolution/evaluation/test_evaluate_os_version.py new file mode 100644 index 000000000..f4535826f --- /dev/null +++ b/tests/resolution/evaluation/test_evaluate_os_version.py @@ -0,0 +1,79 @@ +"""Test OS 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.os.manager import OSManager +from supervisor.resolution.evaluations.os_version import EvaluateOSVersion + + +@pytest.mark.parametrize( + "current,latest,expected", + [ + ("10.0", "15.0", True), # 5 major behind, should be unsupported + ("10.0", "14.0", False), # 4 major behind, should be supported + ("10.2", "11.0", False), # 1 major behind, supported + ("10.4", "10.5", False), # same major, supported + ("10.5", "10.5", False), # up to date, supported + ("10.5", "10.6", False), # same major, supported + ("10.0", "13.3", False), # 3 major behind, supported + (None, "15.0", False), # No current version info, check skipped + ("2.0", None, False), # No latest version info, check skipped + ( + "9ccda431973acf17e4221850b08f3280b723df8d", + "15.0", + False, + ), # Dev setup running on a commit hash, check skipped + ], +) +@pytest.mark.usefixtures("os_available") +async def test_os_version_evaluation( + coresys: CoreSys, current: str | None, latest: str | None, expected: bool +): + """Test evaluation logic on versions.""" + evaluation = EvaluateOSVersion(coresys) + await coresys.core.set_state(CoreState.RUNNING) + with ( + patch.object( + OSManager, + "version", + new=PropertyMock(return_value=current and AwesomeVersion(current)), + ), + patch.object( + OSManager, + "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_did_run(coresys: CoreSys): + """Test that the evaluation ran as expected.""" + evaluation = EvaluateOSVersion(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.os_version.EvaluateOSVersion.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()