diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index b53149123..dd1689aca 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -156,7 +156,7 @@ class HomeAssistantCore(JobGroup): _LOGGER.info("Setting up Home Assistant landingpage") while True: - if not self.sys_updater.image_homeassistant: + if not (install_image := self.sys_homeassistant.install_image): _LOGGER.warning( "Updater has no Home Assistant image information yet. Retrying in %ssec", INSTALL_RETRY_WAIT_SECS, @@ -166,9 +166,7 @@ class HomeAssistantCore(JobGroup): continue try: - await self.instance.install( - LANDINGPAGE, image=self.sys_updater.image_homeassistant - ) + await self.instance.install(LANDINGPAGE, image=install_image) break except DockerError, JobException: pass @@ -182,7 +180,7 @@ class HomeAssistantCore(JobGroup): await asyncio.sleep(INSTALL_RETRY_WAIT_SECS) self.sys_homeassistant.version = LANDINGPAGE - self.sys_homeassistant.set_image(self.sys_updater.image_homeassistant) + self.sys_homeassistant.set_image(install_image) await self.sys_homeassistant.save_data() @Job( @@ -223,6 +221,7 @@ class HomeAssistantCore(JobGroup): _LOGGER.info("Home Assistant Core installation in progress") progress_task = self.sys_create_task(_periodic_progress_log()) + install_image: str | None = None try: while True: # read homeassistant tag and install it @@ -264,10 +263,8 @@ class HomeAssistantCore(JobGroup): ) try: - await self.instance.update( - to_version, - image=self.sys_updater.image_homeassistant, - ) + install_image = self.sys_homeassistant.install_image + await self.instance.update(to_version, image=install_image) self.sys_homeassistant.version = self.instance.version or to_version break except DockerError, JobException: @@ -285,7 +282,7 @@ class HomeAssistantCore(JobGroup): await progress_task _LOGGER.info("Home Assistant docker now installed") - self.sys_homeassistant.set_image(self.sys_updater.image_homeassistant) + self.sys_homeassistant.set_image(install_image) await self.sys_homeassistant.save_data() # finishing @@ -337,6 +334,7 @@ class HomeAssistantCore(JobGroup): ) old_image = self.sys_homeassistant.image + install_image = self.sys_homeassistant.install_image rollback_version = ( self.sys_homeassistant.version if not self.error_state else None ) @@ -364,9 +362,7 @@ class HomeAssistantCore(JobGroup): """Pull the Home Assistant image for the given version.""" _LOGGER.info("Updating Home Assistant to version %s", to_version) try: - await self.instance.update( - to_version, image=self.sys_updater.image_homeassistant - ) + await self.instance.update(to_version, image=install_image) except DockerError as err: raise HomeAssistantUpdateImageError( _LOGGER.warning, version=str(to_version) @@ -375,7 +371,7 @@ class HomeAssistantCore(JobGroup): async def _start_update(to_version: AwesomeVersion) -> None: """Record the new version, (re)start Core and persist the change.""" self.sys_homeassistant.version = self.instance.version or to_version - self.sys_homeassistant.set_image(self.sys_updater.image_homeassistant) + self.sys_homeassistant.set_image(install_image) if running: await self.start() diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 2d0f42f45..f01b9bd79 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -219,6 +219,17 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes): """Enable/disable image override.""" self._data[ATTR_OVERRIDE_IMAGE] = value + @property + def install_image(self) -> str | None: + """Return image to pull when installing or updating Home Assistant Core. + + Uses the user-overridden image if set, otherwise the image from the + update information. + """ + if self.override_image: + return self.image + return self.sys_updater.image_homeassistant + @property def version(self) -> AwesomeVersion | None: """Return version of local version.""" diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 1545704f5..6c0b62272 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -43,6 +43,7 @@ class UnsupportedReason(StrEnum): DNS_SERVER = "dns_server" DOCKER_CONFIGURATION = "docker_configuration" DOCKER_VERSION = "docker_version" + HOME_ASSISTANT_CORE_CUSTOM_IMAGE = "home_assistant_core_custom_image" HOME_ASSISTANT_CORE_VERSION = "home_assistant_core_version" JOB_CONDITIONS = "job_conditions" LXC = "lxc" diff --git a/supervisor/resolution/evaluations/home_assistant_core_custom_image.py b/supervisor/resolution/evaluations/home_assistant_core_custom_image.py new file mode 100644 index 000000000..977282642 --- /dev/null +++ b/supervisor/resolution/evaluations/home_assistant_core_custom_image.py @@ -0,0 +1,34 @@ +"""Evaluation class for Core image.""" + +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 EvaluateHomeAssistantCoreCustomImage(coresys) + + +class EvaluateHomeAssistantCoreCustomImage(EvaluateBase): + """Evaluate the Home Assistant Core image.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.HOME_ASSISTANT_CORE_CUSTOM_IMAGE + + @property + def on_failure(self) -> str: + """Return a string that is printed when self.evaluate is True.""" + return f"Home Assistant Core is using the non-default image '{self.sys_homeassistant.image}'!" + + @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.""" + return self.sys_homeassistant.image != self.sys_homeassistant.default_image diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py index 61cc2da81..dda2f768b 100644 --- a/tests/homeassistant/test_core.py +++ b/tests/homeassistant/test_core.py @@ -28,6 +28,7 @@ from supervisor.homeassistant.api import APIState from supervisor.homeassistant.const import LANDINGPAGE, WSEvent from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.module import HomeAssistant +from supervisor.jobs.const import JobCondition from supervisor.resolution.const import ContextType, IssueType from supervisor.resolution.data import Issue from supervisor.updater import Updater @@ -899,6 +900,71 @@ async def test_core_load_allows_image_override( ) +async def test_install_landingpage_uses_overridden_image(coresys: CoreSys): + """Test landingpage install pulls the user-overridden image.""" + coresys.homeassistant.set_image("myorg/qemux86-64-homeassistant") + coresys.homeassistant.override_image = True + + with ( + patch.object(DockerHomeAssistant, "attach", side_effect=DockerError), + patch.object(DockerHomeAssistant, "install") as install, + ): + await coresys.homeassistant.core.install_landingpage() + + install.assert_called_once_with(LANDINGPAGE, image="myorg/qemux86-64-homeassistant") + assert coresys.homeassistant.image == "myorg/qemux86-64-homeassistant" + assert coresys.homeassistant.version == LANDINGPAGE + + +async def test_install_uses_overridden_image(coresys: CoreSys): + """Test Core install after landingpage pulls the user-overridden image.""" + coresys.homeassistant.set_image("myorg/qemux86-64-homeassistant") + coresys.homeassistant.override_image = True + + with ( + patch.object(HomeAssistantCore, "start"), + patch.object(DockerHomeAssistant, "cleanup"), + patch.object(DockerHomeAssistant, "update") as update, + patch.object( + Updater, + "version_homeassistant", + new=PropertyMock(return_value=AwesomeVersion("2022.7.3")), + ), + ): + await coresys.homeassistant.core.install() + + update.assert_called_once_with( + AwesomeVersion("2022.7.3"), image="myorg/qemux86-64-homeassistant" + ) + assert coresys.homeassistant.image == "myorg/qemux86-64-homeassistant" + + +async def test_update_uses_overridden_image(coresys: CoreSys): + """Test Core update pulls the user-overridden image.""" + coresys.jobs.ignore_conditions = [ + JobCondition.FREE_SPACE, + JobCondition.HEALTHY, + JobCondition.INTERNET_HOST, + JobCondition.PLUGINS_UPDATED, + JobCondition.SUPERVISOR_UPDATED, + ] + coresys.homeassistant.set_image("myorg/qemux86-64-homeassistant") + coresys.homeassistant.override_image = True + coresys.homeassistant.version = AwesomeVersion("2022.7.2") + + with ( + patch.object(DockerHomeAssistant, "update") as update, + patch.object(DockerHomeAssistant, "is_running", return_value=False), + patch.object(DockerHomeAssistant, "exists", return_value=False), + ): + await coresys.homeassistant.core.update(AwesomeVersion("2022.7.3")) + + update.assert_called_once_with( + AwesomeVersion("2022.7.3"), image="myorg/qemux86-64-homeassistant" + ) + assert coresys.homeassistant.image == "myorg/qemux86-64-homeassistant" + + async def test_core_loads_wrong_image_for_architecture( coresys: CoreSys, container: DockerContainer ): diff --git a/tests/resolution/evaluation/test_evaluate_home_assistant_core_custom_image.py b/tests/resolution/evaluation/test_evaluate_home_assistant_core_custom_image.py new file mode 100644 index 000000000..a4b83a41f --- /dev/null +++ b/tests/resolution/evaluation/test_evaluate_home_assistant_core_custom_image.py @@ -0,0 +1,73 @@ +"""Test Core image evaluation.""" + +from unittest.mock import patch + +import pytest + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.evaluations.home_assistant_core_custom_image import ( + EvaluateHomeAssistantCoreCustomImage, +) + + +@pytest.mark.parametrize( + ("image", "expected"), + [ + (None, False), # Unset, default image is used + ("ghcr.io/home-assistant/qemux86-64-homeassistant", False), # Default image + ("myorg/qemux86-64-homeassistant", True), # Fork on another registry + ("ghcr.io/myorg/qemux86-64-homeassistant", True), # Fork on same registry + ], +) +async def test_core_image_evaluation( + coresys: CoreSys, image: str | None, expected: bool +): + """Test evaluation logic on Core image.""" + evaluation = EvaluateHomeAssistantCoreCustomImage(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + coresys.homeassistant.set_image(image) + + assert evaluation.reason not in coresys.resolution.unsupported + await evaluation() + assert (evaluation.reason in coresys.resolution.unsupported) is expected + + +async def test_core_image_evaluation_resolves(coresys: CoreSys): + """Test the unsupported state is removed when the image is set back.""" + evaluation = EvaluateHomeAssistantCoreCustomImage(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + coresys.homeassistant.set_image("myorg/qemux86-64-homeassistant") + await evaluation() + assert evaluation.reason in coresys.resolution.unsupported + + coresys.homeassistant.set_image(None) + await evaluation() + assert evaluation.reason not in coresys.resolution.unsupported + + +async def test_did_run(coresys: CoreSys): + """Test that the evaluation ran as expected.""" + evaluation = EvaluateHomeAssistantCoreCustomImage(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_custom_image.EvaluateHomeAssistantCoreCustomImage.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()