Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions supervisor/resolution/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class UnsupportedReason(StrEnum):
NETWORK_MANAGER = "network_manager"
OS = "os"
OS_AGENT = "os_agent"
OS_VERSION = "os_version"
PRIVILEGED = "privileged"
RESTART_POLICY = "restart_policy"
SOFTWARE = "software"
Expand Down
51 changes: 51 additions & 0 deletions supervisor/resolution/evaluations/os_version.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

@agners agners Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've downgraded to OS 11.5 (which should be the last unsupported version) but it turns out this evaluation is actually not working correctly: this property has the latest version which we are allowed to upgrade, according to the OS upgrade path.

upgrade_map = data.get("hassos-upgrade", {})
if last_in_major := upgrade_map.get(str(self.sys_os.version.major)):
if self.sys_os.version != AwesomeVersion(last_in_major):
version = last_in_major
elif last_in_next_major := upgrade_map.get(
str(int(self.sys_os.version.major) + 1)
):
version = last_in_next_major
self._data[ATTR_HASSOS] = AwesomeVersion(version)

For all recent major OS versions we mandate to upgrade to OS 15.2, so that property is 15.2. But 15 - 4 = 11, so the evaluation returns False. This pretty much makes all OS versions "supported" currently 🙈

I guess we have to store both, the absolutely latest version and the next version we are allowed to upgrade.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shoot. Right, the upgrade path 🙈

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 True
83 changes: 83 additions & 0 deletions tests/resolution/evaluation/test_evaluate_os_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""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
("10.2.dev20240321", "15.0", True), # 5 major behind, dev version, unsupported
("10.2.dev20240321", "13.0", False), # 3 major behind, dev version, supported
("10.2.rc2", "15.0", True), # 5 major behind, rc version, unsupported
("10.2.rc2", "13.0", False), # 3 major behind, rc version, supported
(None, "15.0", False), # No current version info, check skipped
("2.0", None, False), # No latest version info, check skipped
(
"9ccda431973acf17e4221850b08f3280b723df8d",
"15.0",
True,
), # 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()