Skip to content
Merged
1 change: 1 addition & 0 deletions supervisor/jobs/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions supervisor/jobs/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ async def check_conditions(
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
Expand Down
6 changes: 5 additions & 1 deletion supervisor/misc/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.HOME_ASSISTANT_CORE_SUPPORTED,
],
)
async def _reload_store(self) -> None:
"""Reload store and check for addon updates."""
Expand Down
1 change: 1 addition & 0 deletions supervisor/resolution/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
93 changes: 93 additions & 0 deletions supervisor/resolution/evaluations/home_assistant_core_version.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 13 additions & 1 deletion supervisor/resolution/evaluations/os_version.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Evaluation class for OS version."""

import logging

from awesomeversion import AwesomeVersion, AwesomeVersionException

from ...const import CoreState
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."""
Expand Down Expand Up @@ -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
7 changes: 6 additions & 1 deletion supervisor/store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.HOME_ASSISTANT_CORE_SUPPORTED,
],
on_condition=StoreJobError,
)
async def reload(self, repository: Repository | None = None) -> None:
Expand Down Expand Up @@ -117,6 +121,7 @@ async def reload(self, repository: Repository | None = None) -> None:
JobCondition.INTERNET_SYSTEM,
JobCondition.SUPERVISOR_UPDATED,
JobCondition.OS_SUPPORTED,
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
],
on_condition=StoreJobError,
)
Expand Down
6 changes: 5 additions & 1 deletion supervisor/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.HOME_ASSISTANT_CORE_SUPPORTED,
],
on_condition=UpdaterJobError,
throttle_period=timedelta(seconds=30),
concurrency=JobConcurrency.QUEUE,
Expand Down
33 changes: 32 additions & 1 deletion tests/jobs/test_job_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1384,3 +1384,34 @@ 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.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()
Loading
Loading