From 1cb14d699747dfccd401c2684b5d34268e463848 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 2 Sep 2025 18:17:48 +0200 Subject: [PATCH 01/12] 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. --- supervisor/jobs/const.py | 1 + supervisor/jobs/decorator.py | 8 ++ supervisor/resolution/const.py | 1 + .../resolution/evaluations/core_version.py | 72 +++++++++++ tests/jobs/test_job_decorator.py | 30 ++++- .../evaluation/test_evaluate_core_version.py | 121 ++++++++++++++++++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 supervisor/resolution/evaluations/core_version.py create mode 100644 tests/resolution/evaluation/test_evaluate_core_version.py diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index 51b4676e8f8..6bc4ed9cdad 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -20,6 +20,7 @@ class JobCondition(StrEnum): """Job condition enum.""" AUTO_UPDATE = "auto_update" + CORE_SUPPORTED = "core_supported" FREE_SPACE = "free_space" FROZEN = "frozen" HAOS = "haos" diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index c5997aca195..9ff7a54e7df 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -404,6 +404,14 @@ async def check_conditions( f"'{method_name}' blocked from execution, unsupported OS version" ) + if ( + JobCondition.CORE_SUPPORTED in used_conditions + and UnsupportedReason.CORE_VERSION in coresys.sys_resolution.unsupported + ): + raise JobConditionException( + f"'{method_name}' blocked from execution, unsupported Core version" + ) + if ( JobCondition.HOST_NETWORK in used_conditions and not coresys.sys_dbus.network.is_connected diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index a2fb3a97f2b..edc777a3954 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -40,6 +40,7 @@ class UnsupportedReason(StrEnum): CGROUP_VERSION = "cgroup_version" CONNECTIVITY_CHECK = "connectivity_check" CONTENT_TRUST = "content_trust" + CORE_VERSION = "core_version" DBUS = "dbus" DNS_SERVER = "dns_server" DOCKER_CONFIGURATION = "docker_configuration" diff --git a/supervisor/resolution/evaluations/core_version.py b/supervisor/resolution/evaluations/core_version.py new file mode 100644 index 00000000000..bd58222b313 --- /dev/null +++ b/supervisor/resolution/evaluations/core_version.py @@ -0,0 +1,72 @@ +"""Evaluation class for Core version.""" + +from datetime import datetime, timedelta + +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 EvaluateCoreVersion(coresys) + + +class EvaluateCoreVersion(EvaluateBase): + """Evaluate the Home Assistant Core version.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.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 + + try: + # Calculate if the current version was released more than 2 years ago + # Home Assistant releases happen monthly, so approximately 24 versions per 2 years + # However, we'll be more precise and check based on actual version numbers + # Home Assistant follows a versioning scheme like 2024.1, 2024.2, etc. + + # Extract year from current version + current_year = int(str(current).split(".")[0]) + + # Calculate 2 years ago from now + two_years_ago = datetime.now() - timedelta(days=730) # 2 years = 730 days + cutoff_year = two_years_ago.year + cutoff_month = two_years_ago.month + + # Create a cutoff version based on the date 2 years ago + cutoff_version = AwesomeVersion(f"{cutoff_year}.{cutoff_month}") + + # Compare current version with the cutoff + return current < cutoff_version + + except (AwesomeVersionException, ValueError, IndexError): + # If we can't parse the version format, fall back to conservative approach + # Consider unsupported if current is significantly behind latest + try: + # If latest version is from current year and current is from 2+ years ago + latest_year = int(str(latest).split(".")[0]) + current_year = int(str(current).split(".")[0]) + return (latest_year - current_year) >= 2 + except (ValueError, IndexError): + return False diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 9d20b1c0d37..bf03823a868 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -25,7 +25,7 @@ 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,31 @@ async def nested_method(self) -> None: 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.CORE_SUPPORTED] + ) + async def execute(self): + """Execute the class method.""" + return True + + test = TestClass(coresys) + assert await test.execute() + + coresys.resolution.unsupported.append(UnsupportedReason.CORE_VERSION) + assert not await test.execute() + assert "blocked from execution, unsupported Core version" in caplog.text + + coresys.jobs.ignore_conditions = [JobCondition.CORE_SUPPORTED] + assert await test.execute() diff --git a/tests/resolution/evaluation/test_evaluate_core_version.py b/tests/resolution/evaluation/test_evaluate_core_version.py new file mode 100644 index 00000000000..14d1c043ac8 --- /dev/null +++ b/tests/resolution/evaluation/test_evaluate_core_version.py @@ -0,0 +1,121 @@ +"""Test Core Version evaluation.""" + +from datetime import datetime +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.module import HomeAssistant +from supervisor.resolution.evaluations.core_version import EvaluateCoreVersion + + +@pytest.mark.parametrize( + "current,expected", + [ + ("2022.1.0", True), # More than 2 years old, should be unsupported + ("2023.12.0", False), # Less than 2 years old, should be supported + (f"{datetime.now().year}.1", False), # Current year, supported + (f"{datetime.now().year - 1}.12", False), # 1 year old, supported + (f"{datetime.now().year - 2}.1", True), # 2 years old, unsupported + (f"{datetime.now().year - 3}.1", True), # 3 years old, unsupported + ("2021.6.0", True), # Very old version, unsupported + (None, False), # No current version info, check skipped + ], +) +async def test_core_version_evaluation( + coresys: CoreSys, current: str | None, expected: bool +): + """Test evaluation logic on Core versions.""" + evaluation = EvaluateCoreVersion(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=AwesomeVersion("2024.12.0") + ), # Mock latest version + ), + ): + 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 = EvaluateCoreVersion(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() + assert evaluation.reason not in coresys.resolution.unsupported + + +async def test_core_version_invalid_format(coresys: CoreSys): + """Test evaluation with invalid version format.""" + evaluation = EvaluateCoreVersion(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() + # Should handle gracefully and not mark 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 = EvaluateCoreVersion(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.core_version.EvaluateCoreVersion.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() From 417ef54705a1e6156e5dac984f2946d4a61c4136 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 2 Sep 2025 18:19:53 +0200 Subject: [PATCH 02/12] Handle landing page correctly --- .../resolution/evaluations/core_version.py | 16 ++++++++++-- .../evaluation/test_evaluate_core_version.py | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/supervisor/resolution/evaluations/core_version.py b/supervisor/resolution/evaluations/core_version.py index bd58222b313..c7fdb5b41ee 100644 --- a/supervisor/resolution/evaluations/core_version.py +++ b/supervisor/resolution/evaluations/core_version.py @@ -2,10 +2,15 @@ from datetime import datetime, timedelta -from awesomeversion import AwesomeVersion, AwesomeVersionException +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 @@ -40,6 +45,10 @@ async def evaluate(self) -> bool: ): return False + # Skip evaluation for landingpage version + if current == LANDINGPAGE: + return False + try: # Calculate if the current version was released more than 2 years ago # Home Assistant releases happen monthly, so approximately 24 versions per 2 years @@ -55,7 +64,10 @@ async def evaluate(self) -> bool: cutoff_month = two_years_ago.month # Create a cutoff version based on the date 2 years ago - cutoff_version = AwesomeVersion(f"{cutoff_year}.{cutoff_month}") + cutoff_version = AwesomeVersion( + f"{cutoff_year}.{cutoff_month}", + ensure_strategy=AwesomeVersionStrategy.CALVER, + ) # Compare current version with the cutoff return current < cutoff_version diff --git a/tests/resolution/evaluation/test_evaluate_core_version.py b/tests/resolution/evaluation/test_evaluate_core_version.py index 14d1c043ac8..a9fb66a9d44 100644 --- a/tests/resolution/evaluation/test_evaluate_core_version.py +++ b/tests/resolution/evaluation/test_evaluate_core_version.py @@ -8,6 +8,7 @@ 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.core_version import EvaluateCoreVersion @@ -22,6 +23,7 @@ (f"{datetime.now().year - 2}.1", True), # 2 years old, unsupported (f"{datetime.now().year - 3}.1", True), # 3 years old, unsupported ("2021.6.0", True), # Very old version, unsupported + ("landingpage", False), # Landingpage version, should be supported (None, False), # No current version info, check skipped ], ) @@ -96,6 +98,29 @@ async def test_core_version_invalid_format(coresys: CoreSys): assert evaluation.reason not in coresys.resolution.unsupported +async def test_core_version_landingpage(coresys: CoreSys): + """Test evaluation with landingpage version.""" + evaluation = EvaluateCoreVersion(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 = EvaluateCoreVersion(coresys) From 3eaa6ef5e84250e56dff756e1fb3afc5e85181d7 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 4 Sep 2025 14:12:41 +0200 Subject: [PATCH 03/12] Handle non-parseable versions gracefully Also align handling between OS and Core version evaluations. --- .../resolution/evaluations/core_version.py | 32 +++++++++---------- .../resolution/evaluations/os_version.py | 14 +++++++- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/supervisor/resolution/evaluations/core_version.py b/supervisor/resolution/evaluations/core_version.py index c7fdb5b41ee..f1e7fb8f96e 100644 --- a/supervisor/resolution/evaluations/core_version.py +++ b/supervisor/resolution/evaluations/core_version.py @@ -1,6 +1,7 @@ """Evaluation class for Core version.""" from datetime import datetime, timedelta +import logging from awesomeversion import ( AwesomeVersion, @@ -14,6 +15,8 @@ from ..const import UnsupportedReason from .base import EvaluateBase +_LOGGER: logging.Logger = logging.getLogger(__name__) + def setup(coresys: CoreSys) -> EvaluateBase: """Initialize evaluation-setup function.""" @@ -40,9 +43,7 @@ def states(self) -> list[CoreState]: async def evaluate(self) -> bool: """Run evaluation.""" - if not (current := self.sys_homeassistant.version) or not ( - latest := self.sys_homeassistant.latest_version - ): + if not (current := self.sys_homeassistant.version): return False # Skip evaluation for landingpage version @@ -53,10 +54,7 @@ async def evaluate(self) -> bool: # Calculate if the current version was released more than 2 years ago # Home Assistant releases happen monthly, so approximately 24 versions per 2 years # However, we'll be more precise and check based on actual version numbers - # Home Assistant follows a versioning scheme like 2024.1, 2024.2, etc. - - # Extract year from current version - current_year = int(str(current).split(".")[0]) + # Home Assistant uses CalVer versioning scheme like 2024.1, 2024.2, etc. # Calculate 2 years ago from now two_years_ago = datetime.now() - timedelta(days=730) # 2 years = 730 days @@ -72,13 +70,13 @@ async def evaluate(self) -> bool: # Compare current version with the cutoff return current < cutoff_version - except (AwesomeVersionException, ValueError, IndexError): - # If we can't parse the version format, fall back to conservative approach - # Consider unsupported if current is significantly behind latest - try: - # If latest version is from current year and current is from 2+ years ago - latest_year = int(str(latest).split(".")[0]) - current_year = int(str(current).split(".")[0]) - return (latest_year - current_year) >= 2 - except (ValueError, IndexError): - return False + except AwesomeVersionException as err: + # This is run regularly, avoid log spam by logging at debug level + _LOGGER.debug( + "Failed to parse Home Assistant version '%s' or cutoff version '%s': %s", + current, + cutoff_version, + err, + ) + # Consider non-parseable versions as unsupported + return True diff --git a/supervisor/resolution/evaluations/os_version.py b/supervisor/resolution/evaluations/os_version.py index 7aa99379ffe..84976110d83 100644 --- a/supervisor/resolution/evaluations/os_version.py +++ b/supervisor/resolution/evaluations/os_version.py @@ -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 ..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 @@ async def evaluate(self) -> bool: 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 From 07bc8ffeec6178702ec6966f3e0bf3f2d085a8b6 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 9 Sep 2025 23:18:44 +0200 Subject: [PATCH 04/12] Extend and fix test coverage --- .../resolution/evaluation/test_evaluate_core_version.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/resolution/evaluation/test_evaluate_core_version.py b/tests/resolution/evaluation/test_evaluate_core_version.py index a9fb66a9d44..f19468baf3c 100644 --- a/tests/resolution/evaluation/test_evaluate_core_version.py +++ b/tests/resolution/evaluation/test_evaluate_core_version.py @@ -23,6 +23,8 @@ (f"{datetime.now().year - 2}.1", True), # 2 years old, unsupported (f"{datetime.now().year - 3}.1", True), # 3 years old, unsupported ("2021.6.0", True), # Very old version, unsupported + ("0.116.4", True), # Old version scheme, should be unsupported + ("0.118.1", True), # Old version scheme, should be unsupported ("landingpage", False), # Landingpage version, should be supported (None, False), # No current version info, check skipped ], @@ -72,7 +74,8 @@ async def test_core_version_evaluation_no_latest(coresys: CoreSys): ): assert evaluation.reason not in coresys.resolution.unsupported await evaluation() - assert evaluation.reason not in coresys.resolution.unsupported + # Without latest version info, old versions should be marked as unsupported + assert evaluation.reason in coresys.resolution.unsupported async def test_core_version_invalid_format(coresys: CoreSys): @@ -94,8 +97,8 @@ async def test_core_version_invalid_format(coresys: CoreSys): ): assert evaluation.reason not in coresys.resolution.unsupported await evaluation() - # Should handle gracefully and not mark as unsupported - assert evaluation.reason not in coresys.resolution.unsupported + # Invalid/non-parseable versions should be marked as unsupported + assert evaluation.reason in coresys.resolution.unsupported async def test_core_version_landingpage(coresys: CoreSys): From d2a9a6f81452b1b07814b4012205798cd29c6935 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 10 Sep 2025 10:42:33 +0200 Subject: [PATCH 05/12] Improve Job condition error --- supervisor/jobs/decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index 9ff7a54e7df..dbb8ea42513 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -409,7 +409,7 @@ async def check_conditions( and UnsupportedReason.CORE_VERSION in coresys.sys_resolution.unsupported ): raise JobConditionException( - f"'{method_name}' blocked from execution, unsupported Core version" + f"'{method_name}' blocked from execution, unsupported Home Assistant Core version" ) if ( From b99fa27096fef8880cc4f370ffd88d39f6a25259 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 10 Sep 2025 14:18:41 +0200 Subject: [PATCH 06/12] Fix pytest --- tests/jobs/test_job_decorator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index bf03823a868..e01c53b688f 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -1408,7 +1408,9 @@ async def execute(self): coresys.resolution.unsupported.append(UnsupportedReason.CORE_VERSION) assert not await test.execute() - assert "blocked from execution, unsupported Core version" in caplog.text + assert ( + "blocked from execution, unsupported Home Assistant Core version" in caplog.text + ) coresys.jobs.ignore_conditions = [JobCondition.CORE_SUPPORTED] assert await test.execute() From 8ed8c69f90535322141a39d69b9aae7a60783229 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 11 Sep 2025 15:31:52 +0200 Subject: [PATCH 07/12] 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. --- supervisor/misc/tasks.py | 6 +++++- supervisor/store/__init__.py | 7 ++++++- supervisor/updater.py | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 9ca5a4d65e9..0c2555e146e 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -358,7 +358,11 @@ async def _watchdog_addon_application(self): @Job( name="tasks_reload_store", - conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED], + conditions=[ + JobCondition.SUPERVISOR_UPDATED, + JobCondition.OS_SUPPORTED, + JobCondition.CORE_SUPPORTED, + ], ) async def _reload_store(self) -> None: """Reload store and check for addon updates.""" diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index c1fe79f22ee..59b29dfd764 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -74,7 +74,11 @@ async def load(self) -> None: @Job( name="store_manager_reload", - conditions=[JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED], + conditions=[ + JobCondition.SUPERVISOR_UPDATED, + JobCondition.OS_SUPPORTED, + JobCondition.CORE_SUPPORTED, + ], on_condition=StoreJobError, ) async def reload(self, repository: Repository | None = None) -> None: @@ -117,6 +121,7 @@ async def reload(self, repository: Repository | None = None) -> None: JobCondition.INTERNET_SYSTEM, JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED, + JobCondition.CORE_SUPPORTED, ], on_condition=StoreJobError, ) diff --git a/supervisor/updater.py b/supervisor/updater.py index 32932a7df83..bdf245a5d55 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -247,7 +247,11 @@ async def _check_connectivity(self, connectivity: bool): @Job( name="updater_fetch_data", - conditions=[JobCondition.INTERNET_SYSTEM, JobCondition.OS_SUPPORTED], + conditions=[ + JobCondition.INTERNET_SYSTEM, + JobCondition.OS_SUPPORTED, + JobCondition.CORE_SUPPORTED, + ], on_condition=UpdaterJobError, throttle_period=timedelta(seconds=30), concurrency=JobConcurrency.QUEUE, From c91868daa4a82bcd5e4f6f66e563de3dfa0960e8 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 11 Sep 2025 15:48:20 +0200 Subject: [PATCH 08/12] 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. --- .../resolution/evaluations/core_version.py | 50 +++++++++++++------ .../evaluation/test_evaluate_core_version.py | 41 ++++++++------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/supervisor/resolution/evaluations/core_version.py b/supervisor/resolution/evaluations/core_version.py index f1e7fb8f96e..8b152969b2e 100644 --- a/supervisor/resolution/evaluations/core_version.py +++ b/supervisor/resolution/evaluations/core_version.py @@ -1,6 +1,5 @@ """Evaluation class for Core version.""" -from datetime import datetime, timedelta import logging from awesomeversion import ( @@ -43,7 +42,9 @@ def states(self) -> list[CoreState]: async def evaluate(self) -> bool: """Run evaluation.""" - if not (current := self.sys_homeassistant.version): + if not (current := self.sys_homeassistant.version) or not ( + latest := self.sys_homeassistant.latest_version + ): return False # Skip evaluation for landingpage version @@ -51,17 +52,34 @@ async def evaluate(self) -> bool: return False try: - # Calculate if the current version was released more than 2 years ago - # Home Assistant releases happen monthly, so approximately 24 versions per 2 years - # However, we'll be more precise and check based on actual version numbers - # Home Assistant uses CalVer versioning scheme like 2024.1, 2024.2, etc. - - # Calculate 2 years ago from now - two_years_ago = datetime.now() - timedelta(days=730) # 2 years = 730 days - cutoff_year = two_years_ago.year - cutoff_month = two_years_ago.month - - # Create a cutoff version based on the date 2 years ago + # 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 24 releases (approximately 2 years) behind as unsupported. + + # Extract year and month from latest version to calculate cutoff + latest_parts = str(latest).split(".") + if len(latest_parts) < 2: + return True # Invalid latest version format + + latest_year = int(latest_parts[0]) + latest_month = int(latest_parts[1]) + + # Calculate 24 months back from latest version + cutoff_month = latest_month - 24 + cutoff_year = latest_year + + # Handle year rollover + while cutoff_month <= 0: + cutoff_month += 12 + cutoff_year -= 1 + + # Create cutoff version cutoff_version = AwesomeVersion( f"{cutoff_year}.{cutoff_month}", ensure_strategy=AwesomeVersionStrategy.CALVER, @@ -70,12 +88,12 @@ async def evaluate(self) -> bool: # Compare current version with the cutoff return current < cutoff_version - except AwesomeVersionException as err: + 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 cutoff version '%s': %s", + "Failed to parse Home Assistant version '%s' or latest version '%s': %s", current, - cutoff_version, + latest, err, ) # Consider non-parseable versions as unsupported diff --git a/tests/resolution/evaluation/test_evaluate_core_version.py b/tests/resolution/evaluation/test_evaluate_core_version.py index f19468baf3c..fe6028d5983 100644 --- a/tests/resolution/evaluation/test_evaluate_core_version.py +++ b/tests/resolution/evaluation/test_evaluate_core_version.py @@ -1,6 +1,5 @@ """Test Core Version evaluation.""" -from datetime import datetime from unittest.mock import PropertyMock, patch from awesomeversion import AwesomeVersion @@ -14,23 +13,29 @@ @pytest.mark.parametrize( - "current,expected", + "current,latest,expected", [ - ("2022.1.0", True), # More than 2 years old, should be unsupported - ("2023.12.0", False), # Less than 2 years old, should be supported - (f"{datetime.now().year}.1", False), # Current year, supported - (f"{datetime.now().year - 1}.12", False), # 1 year old, supported - (f"{datetime.now().year - 2}.1", True), # 2 years old, unsupported - (f"{datetime.now().year - 3}.1", True), # 3 years old, unsupported - ("2021.6.0", True), # Very old version, unsupported - ("0.116.4", True), # Old version scheme, should be unsupported - ("0.118.1", True), # Old version scheme, should be unsupported - ("landingpage", False), # Landingpage version, should be supported - (None, False), # No current version info, check skipped + ("2022.1.0", "2024.12.0", True), # More than 24 versions behind, unsupported + ("2023.1.0", "2024.12.0", False), # Less than 24 versions 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, expected: bool + coresys: CoreSys, current: str | None, latest: str | None, expected: bool ): """Test evaluation logic on Core versions.""" evaluation = EvaluateCoreVersion(coresys) @@ -45,9 +50,7 @@ async def test_core_version_evaluation( patch.object( HomeAssistant, "latest_version", - new=PropertyMock( - return_value=AwesomeVersion("2024.12.0") - ), # Mock latest version + new=PropertyMock(return_value=latest and AwesomeVersion(latest)), ), ): assert evaluation.reason not in coresys.resolution.unsupported @@ -74,8 +77,8 @@ async def test_core_version_evaluation_no_latest(coresys: CoreSys): ): assert evaluation.reason not in coresys.resolution.unsupported await evaluation() - # Without latest version info, old versions should be marked as unsupported - assert evaluation.reason in coresys.resolution.unsupported + # 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): From e5edc09d130b7d0c18e6db1a2f4a3a932711065c Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 16 Sep 2025 12:00:26 +0200 Subject: [PATCH 09/12] Improve version comparision logic --- .../resolution/evaluations/core_version.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/supervisor/resolution/evaluations/core_version.py b/supervisor/resolution/evaluations/core_version.py index 8b152969b2e..c103b73e254 100644 --- a/supervisor/resolution/evaluations/core_version.py +++ b/supervisor/resolution/evaluations/core_version.py @@ -60,24 +60,17 @@ async def evaluate(self) -> bool: # 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 24 releases (approximately 2 years) behind as unsupported. - - # Extract year and month from latest version to calculate cutoff - latest_parts = str(latest).split(".") - if len(latest_parts) < 2: + # 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 - latest_year = int(latest_parts[0]) - latest_month = int(latest_parts[1]) - # Calculate 24 months back from latest version - cutoff_month = latest_month - 24 - cutoff_year = latest_year - - # Handle year rollover - while cutoff_month <= 0: - cutoff_month += 12 - cutoff_year -= 1 + cutoff_month = int(latest.minor) + cutoff_year = int(latest.year) - 2 # Create cutoff version cutoff_version = AwesomeVersion( From 1286921f3dadc9dda1535574c6f9e4ab9083a10f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 16 Sep 2025 12:04:48 +0200 Subject: [PATCH 10/12] 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. --- supervisor/jobs/const.py | 2 +- supervisor/jobs/decorator.py | 5 +++-- supervisor/misc/tasks.py | 2 +- supervisor/resolution/const.py | 2 +- ...version.py => home_assistant_core_version.py} | 6 +++--- supervisor/store/__init__.py | 4 ++-- supervisor/updater.py | 2 +- tests/jobs/test_job_decorator.py | 7 ++++--- ...test_evaluate_home_assistant_core_version.py} | 16 +++++++++------- 9 files changed, 25 insertions(+), 21 deletions(-) rename supervisor/resolution/evaluations/{core_version.py => home_assistant_core_version.py} (95%) rename tests/resolution/evaluation/{test_evaluate_core_version.py => test_evaluate_home_assistant_core_version.py} (90%) diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index 6bc4ed9cdad..b304afed565 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -20,7 +20,7 @@ class JobCondition(StrEnum): """Job condition enum.""" AUTO_UPDATE = "auto_update" - CORE_SUPPORTED = "core_supported" + HOME_ASSISTANT_CORE_SUPPORTED = "home_assistant_core_supported" FREE_SPACE = "free_space" FROZEN = "frozen" HAOS = "haos" diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index dbb8ea42513..0fc23fdedaf 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -405,8 +405,9 @@ async def check_conditions( ) if ( - JobCondition.CORE_SUPPORTED in used_conditions - and UnsupportedReason.CORE_VERSION in coresys.sys_resolution.unsupported + 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" diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 0c2555e146e..27c5e0e1348 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -361,7 +361,7 @@ async def _watchdog_addon_application(self): conditions=[ JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED, - JobCondition.CORE_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, ], ) async def _reload_store(self) -> None: diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index edc777a3954..e7c7b0f6b33 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -40,7 +40,7 @@ class UnsupportedReason(StrEnum): CGROUP_VERSION = "cgroup_version" CONNECTIVITY_CHECK = "connectivity_check" CONTENT_TRUST = "content_trust" - CORE_VERSION = "core_version" + HOME_ASSISTANT_CORE_VERSION = "home_assistant_core_version" DBUS = "dbus" DNS_SERVER = "dns_server" DOCKER_CONFIGURATION = "docker_configuration" diff --git a/supervisor/resolution/evaluations/core_version.py b/supervisor/resolution/evaluations/home_assistant_core_version.py similarity index 95% rename from supervisor/resolution/evaluations/core_version.py rename to supervisor/resolution/evaluations/home_assistant_core_version.py index c103b73e254..9cb873aff6e 100644 --- a/supervisor/resolution/evaluations/core_version.py +++ b/supervisor/resolution/evaluations/home_assistant_core_version.py @@ -19,16 +19,16 @@ def setup(coresys: CoreSys) -> EvaluateBase: """Initialize evaluation-setup function.""" - return EvaluateCoreVersion(coresys) + return EvaluateHomeAssistantCoreVersion(coresys) -class EvaluateCoreVersion(EvaluateBase): +class EvaluateHomeAssistantCoreVersion(EvaluateBase): """Evaluate the Home Assistant Core version.""" @property def reason(self) -> UnsupportedReason: """Return a UnsupportedReason enum.""" - return UnsupportedReason.CORE_VERSION + return UnsupportedReason.HOME_ASSISTANT_CORE_VERSION @property def on_failure(self) -> str: diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index 59b29dfd764..c8529f4775e 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -77,7 +77,7 @@ async def load(self) -> None: conditions=[ JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED, - JobCondition.CORE_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, ], on_condition=StoreJobError, ) @@ -121,7 +121,7 @@ async def reload(self, repository: Repository | None = None) -> None: JobCondition.INTERNET_SYSTEM, JobCondition.SUPERVISOR_UPDATED, JobCondition.OS_SUPPORTED, - JobCondition.CORE_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, ], on_condition=StoreJobError, ) diff --git a/supervisor/updater.py b/supervisor/updater.py index bdf245a5d55..a4b837e46e8 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -250,7 +250,7 @@ async def _check_connectivity(self, connectivity: bool): conditions=[ JobCondition.INTERNET_SYSTEM, JobCondition.OS_SUPPORTED, - JobCondition.CORE_SUPPORTED, + JobCondition.HOME_ASSISTANT_CORE_SUPPORTED, ], on_condition=UpdaterJobError, throttle_period=timedelta(seconds=30), diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index e01c53b688f..1c61eef9685 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -1397,7 +1397,8 @@ def __init__(self, coresys: CoreSys): self.coresys = coresys @Job( - name="test_core_supported_execute", conditions=[JobCondition.CORE_SUPPORTED] + name="test_core_supported_execute", + conditions=[JobCondition.HOME_ASSISTANT_CORE_SUPPORTED], ) async def execute(self): """Execute the class method.""" @@ -1406,11 +1407,11 @@ async def execute(self): test = TestClass(coresys) assert await test.execute() - coresys.resolution.unsupported.append(UnsupportedReason.CORE_VERSION) + 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.CORE_SUPPORTED] + coresys.jobs.ignore_conditions = [JobCondition.HOME_ASSISTANT_CORE_SUPPORTED] assert await test.execute() diff --git a/tests/resolution/evaluation/test_evaluate_core_version.py b/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py similarity index 90% rename from tests/resolution/evaluation/test_evaluate_core_version.py rename to tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py index fe6028d5983..e0d33ffcd5c 100644 --- a/tests/resolution/evaluation/test_evaluate_core_version.py +++ b/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py @@ -9,7 +9,9 @@ from supervisor.coresys import CoreSys from supervisor.homeassistant.const import LANDINGPAGE from supervisor.homeassistant.module import HomeAssistant -from supervisor.resolution.evaluations.core_version import EvaluateCoreVersion +from supervisor.resolution.evaluations.home_assistant_core_version import ( + EvaluateHomeAssistantCoreVersion, +) @pytest.mark.parametrize( @@ -38,7 +40,7 @@ async def test_core_version_evaluation( coresys: CoreSys, current: str | None, latest: str | None, expected: bool ): """Test evaluation logic on Core versions.""" - evaluation = EvaluateCoreVersion(coresys) + evaluation = EvaluateHomeAssistantCoreVersion(coresys) await coresys.core.set_state(CoreState.RUNNING) with ( @@ -60,7 +62,7 @@ async def test_core_version_evaluation( async def test_core_version_evaluation_no_latest(coresys: CoreSys): """Test evaluation when no latest version is available.""" - evaluation = EvaluateCoreVersion(coresys) + evaluation = EvaluateHomeAssistantCoreVersion(coresys) await coresys.core.set_state(CoreState.RUNNING) with ( @@ -83,7 +85,7 @@ async def test_core_version_evaluation_no_latest(coresys: CoreSys): async def test_core_version_invalid_format(coresys: CoreSys): """Test evaluation with invalid version format.""" - evaluation = EvaluateCoreVersion(coresys) + evaluation = EvaluateHomeAssistantCoreVersion(coresys) await coresys.core.set_state(CoreState.RUNNING) with ( @@ -106,7 +108,7 @@ async def test_core_version_invalid_format(coresys: CoreSys): async def test_core_version_landingpage(coresys: CoreSys): """Test evaluation with landingpage version.""" - evaluation = EvaluateCoreVersion(coresys) + evaluation = EvaluateHomeAssistantCoreVersion(coresys) await coresys.core.set_state(CoreState.RUNNING) with ( @@ -129,14 +131,14 @@ async def test_core_version_landingpage(coresys: CoreSys): async def test_did_run(coresys: CoreSys): """Test that the evaluation ran as expected.""" - evaluation = EvaluateCoreVersion(coresys) + 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.core_version.EvaluateCoreVersion.evaluate", + "supervisor.resolution.evaluations.home_assistant_core_version.EvaluateHomeAssistantCoreVersion.evaluate", return_value=None, ) as evaluate: for state in should_run: From efa54bc4cc1fa92c9f48c792c62393389b53ed6f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 16 Sep 2025 17:04:52 +0200 Subject: [PATCH 11/12] Sort const alphabetically --- supervisor/jobs/const.py | 2 +- supervisor/resolution/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index b304afed565..9a0d4af1d19 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -20,11 +20,11 @@ class JobCondition(StrEnum): """Job condition enum.""" AUTO_UPDATE = "auto_update" - HOME_ASSISTANT_CORE_SUPPORTED = "home_assistant_core_supported" FREE_SPACE = "free_space" 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" diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index e7c7b0f6b33..6ce87a7e9a3 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -40,11 +40,11 @@ class UnsupportedReason(StrEnum): CGROUP_VERSION = "cgroup_version" CONNECTIVITY_CHECK = "connectivity_check" CONTENT_TRUST = "content_trust" - HOME_ASSISTANT_CORE_VERSION = "home_assistant_core_version" DBUS = "dbus" 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" From 286553ae9f7fd3849c60a8789d73ee3f4a19146d Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 18 Sep 2025 13:50:49 +0200 Subject: [PATCH 12/12] Update tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../evaluation/test_evaluate_home_assistant_core_version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py b/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py index e0d33ffcd5c..6171dcb2290 100644 --- a/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py +++ b/tests/resolution/evaluation/test_evaluate_home_assistant_core_version.py @@ -17,8 +17,8 @@ @pytest.mark.parametrize( "current,latest,expected", [ - ("2022.1.0", "2024.12.0", True), # More than 24 versions behind, unsupported - ("2023.1.0", "2024.12.0", False), # Less than 24 versions behind, supported + ("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