Skip to content

Commit c961ae3

Browse files
committed
Check Core version and raise unsupported if older than 2 years
Check the currently installed Core version relative to the current date, and if its older than 2 years, mark the system unsupported. Also add a Job condition to prevent automatic refreshing of the update information in this case.
1 parent 4513592 commit c961ae3

File tree

6 files changed

+232
-1
lines changed

6 files changed

+232
-1
lines changed

supervisor/jobs/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class JobCondition(StrEnum):
2020
"""Job condition enum."""
2121

2222
AUTO_UPDATE = "auto_update"
23+
CORE_SUPPORTED = "core_supported"
2324
FREE_SPACE = "free_space"
2425
FROZEN = "frozen"
2526
HAOS = "haos"

supervisor/jobs/decorator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,14 @@ async def check_conditions(
404404
f"'{method_name}' blocked from execution, unsupported OS version"
405405
)
406406

407+
if (
408+
JobCondition.CORE_SUPPORTED in used_conditions
409+
and UnsupportedReason.CORE_VERSION in coresys.sys_resolution.unsupported
410+
):
411+
raise JobConditionException(
412+
f"'{method_name}' blocked from execution, unsupported Core version"
413+
)
414+
407415
if (
408416
JobCondition.HOST_NETWORK in used_conditions
409417
and not coresys.sys_dbus.network.is_connected

supervisor/resolution/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class UnsupportedReason(StrEnum):
4040
CGROUP_VERSION = "cgroup_version"
4141
CONNECTIVITY_CHECK = "connectivity_check"
4242
CONTENT_TRUST = "content_trust"
43+
CORE_VERSION = "core_version"
4344
DBUS = "dbus"
4445
DNS_SERVER = "dns_server"
4546
DOCKER_CONFIGURATION = "docker_configuration"
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Evaluation class for Core version."""
2+
3+
from datetime import datetime, timedelta
4+
5+
from awesomeversion import AwesomeVersion, AwesomeVersionException
6+
7+
from ...const import CoreState
8+
from ...coresys import CoreSys
9+
from ..const import UnsupportedReason
10+
from .base import EvaluateBase
11+
12+
13+
def setup(coresys: CoreSys) -> EvaluateBase:
14+
"""Initialize evaluation-setup function."""
15+
return EvaluateCoreVersion(coresys)
16+
17+
18+
class EvaluateCoreVersion(EvaluateBase):
19+
"""Evaluate the Home Assistant Core version."""
20+
21+
@property
22+
def reason(self) -> UnsupportedReason:
23+
"""Return a UnsupportedReason enum."""
24+
return UnsupportedReason.CORE_VERSION
25+
26+
@property
27+
def on_failure(self) -> str:
28+
"""Return a string that is printed when self.evaluate is True."""
29+
return f"Home Assistant Core version '{self.sys_homeassistant.version}' is more than 2 years old!"
30+
31+
@property
32+
def states(self) -> list[CoreState]:
33+
"""Return a list of valid states when this evaluation can run."""
34+
return [CoreState.RUNNING, CoreState.SETUP]
35+
36+
async def evaluate(self) -> bool:
37+
"""Run evaluation."""
38+
if not (current := self.sys_homeassistant.version) or not (
39+
latest := self.sys_homeassistant.latest_version
40+
):
41+
return False
42+
43+
try:
44+
# Calculate if the current version was released more than 2 years ago
45+
# Home Assistant releases happen monthly, so approximately 24 versions per 2 years
46+
# However, we'll be more precise and check based on actual version numbers
47+
# Home Assistant follows a versioning scheme like 2024.1, 2024.2, etc.
48+
49+
# Extract year from current version
50+
current_year = int(str(current).split(".")[0])
51+
52+
# Calculate 2 years ago from now
53+
two_years_ago = datetime.now() - timedelta(days=730) # 2 years = 730 days
54+
cutoff_year = two_years_ago.year
55+
cutoff_month = two_years_ago.month
56+
57+
# Create a cutoff version based on the date 2 years ago
58+
cutoff_version = AwesomeVersion(f"{cutoff_year}.{cutoff_month}")
59+
60+
# Compare current version with the cutoff
61+
return current < cutoff_version
62+
63+
except (AwesomeVersionException, ValueError, IndexError):
64+
# If we can't parse the version format, fall back to conservative approach
65+
# Consider unsupported if current is significantly behind latest
66+
try:
67+
# If latest version is from current year and current is from 2+ years ago
68+
latest_year = int(str(latest).split(".")[0])
69+
current_year = int(str(current).split(".")[0])
70+
return (latest_year - current_year) >= 2
71+
except (ValueError, IndexError):
72+
return False

tests/jobs/test_job_decorator.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from supervisor.jobs.job_group import JobGroup
2626
from supervisor.os.manager import OSManager
2727
from supervisor.plugins.audio import PluginAudio
28-
from supervisor.resolution.const import UnhealthyReason
28+
from supervisor.resolution.const import UnhealthyReason, UnsupportedReason
2929
from supervisor.supervisor import Supervisor
3030
from supervisor.utils.dt import utcnow
3131

@@ -1384,3 +1384,31 @@ async def nested_method(self) -> None:
13841384

13851385
assert test.call_count == 2 # Should execute now
13861386
assert test.nested_call_count == 2 # Nested call should also execute
1387+
1388+
1389+
async def test_core_supported(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
1390+
"""Test the core_supported decorator."""
1391+
1392+
class TestClass:
1393+
"""Test class."""
1394+
1395+
def __init__(self, coresys: CoreSys):
1396+
"""Initialize the test class."""
1397+
self.coresys = coresys
1398+
1399+
@Job(
1400+
name="test_core_supported_execute", conditions=[JobCondition.CORE_SUPPORTED]
1401+
)
1402+
async def execute(self):
1403+
"""Execute the class method."""
1404+
return True
1405+
1406+
test = TestClass(coresys)
1407+
assert await test.execute()
1408+
1409+
coresys.resolution.unsupported.append(UnsupportedReason.CORE_VERSION)
1410+
assert not await test.execute()
1411+
assert "blocked from execution, unsupported Core version" in caplog.text
1412+
1413+
coresys.jobs.ignore_conditions = [JobCondition.CORE_SUPPORTED]
1414+
assert await test.execute()
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Test Core Version evaluation."""
2+
3+
from datetime import datetime
4+
from unittest.mock import PropertyMock, patch
5+
6+
from awesomeversion import AwesomeVersion
7+
import pytest
8+
9+
from supervisor.const import CoreState
10+
from supervisor.coresys import CoreSys
11+
from supervisor.homeassistant.module import HomeAssistant
12+
from supervisor.resolution.evaluations.core_version import EvaluateCoreVersion
13+
14+
15+
@pytest.mark.parametrize(
16+
"current,expected",
17+
[
18+
("2022.1.0", True), # More than 2 years old, should be unsupported
19+
("2023.12.0", False), # Less than 2 years old, should be supported
20+
(f"{datetime.now().year}.1", False), # Current year, supported
21+
(f"{datetime.now().year - 1}.12", False), # 1 year old, supported
22+
(f"{datetime.now().year - 2}.1", True), # 2 years old, unsupported
23+
(f"{datetime.now().year - 3}.1", True), # 3 years old, unsupported
24+
("2021.6.0", True), # Very old version, unsupported
25+
(None, False), # No current version info, check skipped
26+
],
27+
)
28+
async def test_core_version_evaluation(
29+
coresys: CoreSys, current: str | None, expected: bool
30+
):
31+
"""Test evaluation logic on Core versions."""
32+
evaluation = EvaluateCoreVersion(coresys)
33+
await coresys.core.set_state(CoreState.RUNNING)
34+
35+
with (
36+
patch.object(
37+
HomeAssistant,
38+
"version",
39+
new=PropertyMock(return_value=current and AwesomeVersion(current)),
40+
),
41+
patch.object(
42+
HomeAssistant,
43+
"latest_version",
44+
new=PropertyMock(
45+
return_value=AwesomeVersion("2024.12.0")
46+
), # Mock latest version
47+
),
48+
):
49+
assert evaluation.reason not in coresys.resolution.unsupported
50+
await evaluation()
51+
assert (evaluation.reason in coresys.resolution.unsupported) is expected
52+
53+
54+
async def test_core_version_evaluation_no_latest(coresys: CoreSys):
55+
"""Test evaluation when no latest version is available."""
56+
evaluation = EvaluateCoreVersion(coresys)
57+
await coresys.core.set_state(CoreState.RUNNING)
58+
59+
with (
60+
patch.object(
61+
HomeAssistant,
62+
"version",
63+
new=PropertyMock(return_value=AwesomeVersion("2022.1.0")),
64+
),
65+
patch.object(
66+
HomeAssistant,
67+
"latest_version",
68+
new=PropertyMock(return_value=None),
69+
),
70+
):
71+
assert evaluation.reason not in coresys.resolution.unsupported
72+
await evaluation()
73+
assert evaluation.reason not in coresys.resolution.unsupported
74+
75+
76+
async def test_core_version_invalid_format(coresys: CoreSys):
77+
"""Test evaluation with invalid version format."""
78+
evaluation = EvaluateCoreVersion(coresys)
79+
await coresys.core.set_state(CoreState.RUNNING)
80+
81+
with (
82+
patch.object(
83+
HomeAssistant,
84+
"version",
85+
new=PropertyMock(return_value=AwesomeVersion("invalid.version")),
86+
),
87+
patch.object(
88+
HomeAssistant,
89+
"latest_version",
90+
new=PropertyMock(return_value=AwesomeVersion("2024.12.0")),
91+
),
92+
):
93+
assert evaluation.reason not in coresys.resolution.unsupported
94+
await evaluation()
95+
# Should handle gracefully and not mark as unsupported
96+
assert evaluation.reason not in coresys.resolution.unsupported
97+
98+
99+
async def test_did_run(coresys: CoreSys):
100+
"""Test that the evaluation ran as expected."""
101+
evaluation = EvaluateCoreVersion(coresys)
102+
should_run = evaluation.states
103+
should_not_run = [state for state in CoreState if state not in should_run]
104+
assert len(should_run) != 0
105+
assert len(should_not_run) != 0
106+
107+
with patch(
108+
"supervisor.resolution.evaluations.core_version.EvaluateCoreVersion.evaluate",
109+
return_value=None,
110+
) as evaluate:
111+
for state in should_run:
112+
await coresys.core.set_state(state)
113+
await evaluation()
114+
evaluate.assert_called_once()
115+
evaluate.reset_mock()
116+
117+
for state in should_not_run:
118+
await coresys.core.set_state(state)
119+
await evaluation()
120+
evaluate.assert_not_called()
121+
evaluate.reset_mock()

0 commit comments

Comments
 (0)