Check Core version and raise unsupported if older than 2 years (#6148)

* 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.

* Handle landing page correctly

* Handle non-parseable versions gracefully

Also align handling between OS and Core version evaluations.

* Extend and fix test coverage

* Improve Job condition error

* Fix pytest

* Block execution of fetch_data and store reload jobs

Block execution of fetch_data and store reload jobs if the core version
is unsupported. This essentially freezes the installation until the
user takes action and updates the Core version to a supported one.

* Use latest known Core version as reference

Instead of using current date to determine if Core version is more than
2 years old, use the latest known Core version as reference point and
check if current version is more than 24 releases behind.

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 users update to the last known version in 4+ years, the
system would remain unsupported. By using latest known version as reference,
updating Core to the last known version makes the system supported again,
allowing update information refresh to resume.

This ensures users can always escape the unsupported state by updating
to the last known Core version, maintaining the update refresh cycle.

* Improve version comparision logic

* Use Home Assistant Core instead of just Core

Avoid any ambiguity in what is exactly outdated/unsupported by using
Home Assistant Core instead of just Core.

* Sort const alphabetically

* Update tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Stefan Agner 2025-09-19 17:58:37 +02:00 committed by GitHub
parent 46fc5c8aa1
commit c712d3cc53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 319 additions and 5 deletions

View File

@ -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"

View File

@ -404,6 +404,15 @@ class Job(CoreSysAttributes):
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

View File

@ -358,7 +358,11 @@ class Tasks(CoreSysAttributes):
@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."""

View File

@ -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"

View File

@ -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

View File

@ -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 ...coresys import CoreSys
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 @@ class EvaluateOSVersion(EvaluateBase):
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

View File

@ -74,7 +74,11 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
@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 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
JobCondition.INTERNET_SYSTEM,
JobCondition.SUPERVISOR_UPDATED,
JobCondition.OS_SUPPORTED,
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
],
on_condition=StoreJobError,
)

View File

@ -247,7 +247,11 @@ class Updater(FileConfiguration, CoreSysAttributes):
@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,

View File

@ -25,7 +25,7 @@ from supervisor.jobs.decorator import Job, JobCondition
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 test_group_concurrency_with_group_throttling(coresys: CoreSys):
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()

View File

@ -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()