Respect user-overridden Core image for install and update (#7092)

* Respect user-overridden Core image for install and update

The override_image setting was only honored by load(): the landingpage
install, the initial Core install and Core updates always pulled the
image from the update information and wrote it back to the Home
Assistant config afterwards, discarding the user override on the next
install or update.

Add a HomeAssistant.install_image property that returns the
user-overridden image if set and the image from the update information
otherwise. Use it in the landingpage install, Core install and Core
update paths, and persist that same image afterwards so the override
is kept. Version resolution is unchanged. With an override set, a
fresh install now pulls <image>:landingpage from the overridden image
as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Mark system unsupported when a custom Core image is used

As raised in review, running a Home Assistant Core fork should be
visible: add an evaluation that marks the system unsupported when the
configured Core image differs from the default image for the machine.

The evaluation compares the configured image against the default image
instead of checking the override_image flag, so it also catches images
recorded through container adoption or manual configuration edits
where the flag is not set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Capture install image once per Core install and update

As raised in review, the install image was read separately for the
image pull and for persisting it to the Home Assistant config. If the
image option changes while an install or update job is running, the
pulled and the persisted image could diverge. Capture the value once
per install attempt respectively once per update job (covering the
rollback path) and use it for both the pull and set_image().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-08-04 10:36:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c5ea8e174e
commit d30db522c2
6 changed files with 195 additions and 14 deletions
+10 -14
View File
@@ -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()
+11
View File
@@ -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."""
+1
View File
@@ -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"
@@ -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
+66
View File
@@ -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
):
@@ -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()