mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-12-10 16:52:15 -06:00
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:
parent
46fc5c8aa1
commit
c712d3cc53
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user