mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-14 20:56:30 +00:00
Capture exception if image is missing on run (#4621)
* Retry run if image missing and handle fixup * Fix lint and run error test * Remove retry and just capture exception
This commit is contained in:
parent
ab6745bc99
commit
77fd1b4017
@ -501,24 +501,16 @@ class DockerAddon(DockerInterface):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Security check
|
||||
if not self.addon.protected:
|
||||
_LOGGER.warning("%s running with disabled protected mode!", self.addon.name)
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Don't set a hostname if no separate UTS namespace is used
|
||||
hostname = None if self.uts_mode else self.addon.hostname
|
||||
|
||||
# Create & Run container
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
tag=str(self.addon.version),
|
||||
name=self.name,
|
||||
hostname=hostname,
|
||||
@ -549,7 +541,6 @@ class DockerAddon(DockerInterface):
|
||||
)
|
||||
raise
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting Docker add-on %s with version %s", self.image, self.version
|
||||
)
|
||||
|
@ -92,16 +92,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
tag=str(self.sys_plugins.audio.version),
|
||||
init=False,
|
||||
ipv4=self.sys_docker.network.audio,
|
||||
@ -118,8 +109,6 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
},
|
||||
mounts=self.mounts,
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting Audio %s with version %s - %s",
|
||||
self.image,
|
||||
|
@ -33,16 +33,7 @@ class DockerCli(DockerInterface, CoreSysAttributes):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
entrypoint=["/init"],
|
||||
tag=str(self.sys_plugins.cli.version),
|
||||
init=False,
|
||||
@ -60,8 +51,6 @@ class DockerCli(DockerInterface, CoreSysAttributes):
|
||||
ENV_TOKEN: self.sys_plugins.cli.supervisor_token,
|
||||
},
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting CLI %s with version %s - %s",
|
||||
self.image,
|
||||
|
@ -35,16 +35,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
tag=str(self.sys_plugins.dns.version),
|
||||
init=False,
|
||||
dns=False,
|
||||
@ -65,8 +56,6 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
|
||||
],
|
||||
oom_score_adj=-300,
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting DNS %s with version %s - %s",
|
||||
self.image,
|
||||
|
@ -152,16 +152,7 @@ class DockerHomeAssistant(DockerInterface):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
tag=(self.sys_homeassistant.version),
|
||||
name=self.name,
|
||||
hostname=self.name,
|
||||
@ -186,8 +177,6 @@ class DockerHomeAssistant(DockerInterface):
|
||||
tmpfs={"/tmp": ""},
|
||||
oom_score_adj=-300,
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting Home Assistant %s with version %s", self.image, self.version
|
||||
)
|
||||
|
@ -377,6 +377,27 @@ class DockerInterface(JobGroup):
|
||||
"""Run Docker image."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def _run(self, **kwargs) -> None:
|
||||
"""Run Docker image with retry inf necessary."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
try:
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run, self.image, **kwargs
|
||||
)
|
||||
except DockerNotFound as err:
|
||||
# If image is missing, capture the exception as this shouldn't happen
|
||||
capture_exception(err)
|
||||
raise
|
||||
|
||||
# Store metadata
|
||||
self._meta = docker_container.attrs
|
||||
|
||||
@Job(
|
||||
name="docker_interface_stop",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
|
@ -38,16 +38,7 @@ class DockerMulticast(DockerInterface, CoreSysAttributes):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
tag=str(self.sys_plugins.multicast.version),
|
||||
init=False,
|
||||
name=self.name,
|
||||
@ -59,8 +50,6 @@ class DockerMulticast(DockerInterface, CoreSysAttributes):
|
||||
extra_hosts={"supervisor": self.sys_docker.network.supervisor},
|
||||
environment={ENV_TIME: self.sys_timezone},
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting Multicast %s with version %s - Host", self.image, self.version
|
||||
)
|
||||
|
@ -35,16 +35,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes):
|
||||
)
|
||||
async def run(self) -> None:
|
||||
"""Run Docker image."""
|
||||
if await self.is_running():
|
||||
return
|
||||
|
||||
# Cleanup
|
||||
await self.stop()
|
||||
|
||||
# Create & Run container
|
||||
docker_container = await self.sys_run_in_executor(
|
||||
self.sys_docker.run,
|
||||
self.image,
|
||||
await self._run(
|
||||
tag=str(self.sys_plugins.observer.version),
|
||||
init=False,
|
||||
ipv4=self.sys_docker.network.observer,
|
||||
@ -63,8 +54,6 @@ class DockerObserver(DockerInterface, CoreSysAttributes):
|
||||
ports={"80/tcp": 4357},
|
||||
oom_score_adj=-300,
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Starting Observer %s with version %s - %s",
|
||||
self.image,
|
||||
|
57
supervisor/resolution/fixups/addon_execute_repair.py
Normal file
57
supervisor/resolution/fixups/addon_execute_repair.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""Helper to fix missing image for addon."""
|
||||
|
||||
import logging
|
||||
|
||||
from ...coresys import CoreSys
|
||||
from ..const import ContextType, IssueType, SuggestionType
|
||||
from .base import FixupBase
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(coresys: CoreSys) -> FixupBase:
|
||||
"""Check setup function."""
|
||||
return FixupAddonExecuteRepair(coresys)
|
||||
|
||||
|
||||
class FixupAddonExecuteRepair(FixupBase):
|
||||
"""Storage class for fixup."""
|
||||
|
||||
async def process_fixup(self, reference: str | None = None) -> None:
|
||||
"""Pull the addons image."""
|
||||
addon = self.sys_addons.get(reference, local_only=True)
|
||||
if not addon:
|
||||
_LOGGER.info(
|
||||
"Cannot repair addon %s as it is not installed, dismissing suggestion",
|
||||
reference,
|
||||
)
|
||||
return
|
||||
|
||||
if await addon.instance.exists():
|
||||
_LOGGER.info(
|
||||
"Addon %s does not need repair, dismissing suggestion", reference
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.info("Installing image for addon %s")
|
||||
await addon.instance.install(addon.version)
|
||||
|
||||
@property
|
||||
def suggestion(self) -> SuggestionType:
|
||||
"""Return a SuggestionType enum."""
|
||||
return SuggestionType.EXECUTE_REPAIR
|
||||
|
||||
@property
|
||||
def context(self) -> ContextType:
|
||||
"""Return a ContextType enum."""
|
||||
return ContextType.ADDON
|
||||
|
||||
@property
|
||||
def issues(self) -> list[IssueType]:
|
||||
"""Return a IssueType enum list."""
|
||||
return [IssueType.MISSING_IMAGE]
|
||||
|
||||
@property
|
||||
def auto(self) -> bool:
|
||||
"""Return if a fixup can be apply as auto fix."""
|
||||
return True
|
@ -39,13 +39,6 @@ def fixture_addonsdata_user() -> dict[str, Data]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(name="os_environ")
|
||||
def fixture_os_environ():
|
||||
"""Mock os.environ."""
|
||||
with patch("supervisor.config.os.environ") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def get_docker_addon(
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], config_file: str
|
||||
):
|
||||
@ -60,7 +53,7 @@ def get_docker_addon(
|
||||
|
||||
|
||||
def test_base_volumes_included(
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
|
||||
):
|
||||
"""Dev and data volumes always included."""
|
||||
docker_addon = get_docker_addon(
|
||||
@ -86,7 +79,7 @@ def test_base_volumes_included(
|
||||
|
||||
|
||||
def test_addon_map_folder_defaults(
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
|
||||
):
|
||||
"""Validate defaults for mapped folders in addons."""
|
||||
docker_addon = get_docker_addon(
|
||||
@ -143,7 +136,7 @@ def test_addon_map_folder_defaults(
|
||||
|
||||
|
||||
def test_journald_addon(
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
|
||||
):
|
||||
"""Validate volume for journald option."""
|
||||
docker_addon = get_docker_addon(
|
||||
@ -171,7 +164,7 @@ def test_journald_addon(
|
||||
|
||||
|
||||
def test_not_journald_addon(
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], os_environ
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
|
||||
):
|
||||
"""Validate journald option defaults off."""
|
||||
docker_addon = get_docker_addon(
|
||||
@ -182,10 +175,7 @@ def test_not_journald_addon(
|
||||
|
||||
|
||||
async def test_addon_run_docker_error(
|
||||
coresys: CoreSys,
|
||||
addonsdata_system: dict[str, Data],
|
||||
capture_exception: Mock,
|
||||
os_environ,
|
||||
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
|
||||
):
|
||||
"""Test docker error when addon is run."""
|
||||
await coresys.dbus.timedate.connect(coresys.dbus.bus)
|
||||
@ -203,14 +193,13 @@ async def test_addon_run_docker_error(
|
||||
Issue(IssueType.MISSING_IMAGE, ContextType.ADDON, reference="test_addon")
|
||||
in coresys.resolution.issues
|
||||
)
|
||||
capture_exception.assert_not_called()
|
||||
|
||||
|
||||
async def test_addon_run_add_host_error(
|
||||
coresys: CoreSys,
|
||||
addonsdata_system: dict[str, Data],
|
||||
capture_exception: Mock,
|
||||
os_environ,
|
||||
path_extern,
|
||||
):
|
||||
"""Test error adding host when addon is run."""
|
||||
await coresys.dbus.timedate.connect(coresys.dbus.bus)
|
||||
|
@ -10,12 +10,18 @@ from docker.models.images import Image
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
|
||||
from supervisor.addons import Addon
|
||||
from supervisor.const import BusEvent, CpuArch
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.interface import DockerInterface
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.exceptions import DockerAPIError, DockerError, DockerRequestError
|
||||
from supervisor.exceptions import (
|
||||
DockerAPIError,
|
||||
DockerError,
|
||||
DockerNotFound,
|
||||
DockerRequestError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -223,3 +229,21 @@ async def test_image_pull_fail(
|
||||
)
|
||||
|
||||
capture_exception.assert_called_once_with(err)
|
||||
|
||||
|
||||
async def test_run_missing_image(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
capture_exception: Mock,
|
||||
path_extern,
|
||||
):
|
||||
"""Test run captures the exception when image is missing."""
|
||||
coresys.docker.containers.create.side_effect = [NotFound("missing"), MagicMock()]
|
||||
container.status = "stopped"
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
with pytest.raises(DockerNotFound):
|
||||
await install_addon_ssh.instance.run()
|
||||
|
||||
capture_exception.assert_called_once()
|
||||
|
73
tests/resolution/fixup/test_addon_execute_repair.py
Normal file
73
tests/resolution/fixup/test_addon_execute_repair.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Test fixup core execute repair."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from docker.errors import NotFound
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.addon import DockerAddon
|
||||
from supervisor.docker.interface import DockerInterface
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||
from supervisor.resolution.fixups.addon_execute_repair import FixupAddonExecuteRepair
|
||||
|
||||
|
||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
|
||||
"""Test fixup rebuilds addon's container."""
|
||||
docker.images.get.side_effect = NotFound("missing")
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
assert addon_execute_repair.auto is True
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
IssueType.MISSING_IMAGE,
|
||||
ContextType.ADDON,
|
||||
reference="local_ssh",
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
with patch.object(DockerInterface, "install") as install:
|
||||
await addon_execute_repair()
|
||||
install.assert_called_once()
|
||||
|
||||
assert not coresys.resolution.issues
|
||||
assert not coresys.resolution.suggestions
|
||||
|
||||
|
||||
async def test_fixup_no_addon(coresys: CoreSys):
|
||||
"""Test fixup dismisses if addon is missing."""
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
assert addon_execute_repair.auto is True
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
IssueType.MISSING_IMAGE,
|
||||
ContextType.ADDON,
|
||||
reference="local_ssh",
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
|
||||
with patch.object(DockerAddon, "install") as install:
|
||||
await addon_execute_repair()
|
||||
install.assert_not_called()
|
||||
|
||||
|
||||
async def test_fixup_image_exists(
|
||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||
):
|
||||
"""Test fixup dismisses if image exists."""
|
||||
docker.images.get.return_value = MagicMock()
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
assert addon_execute_repair.auto is True
|
||||
|
||||
coresys.resolution.create_issue(
|
||||
IssueType.MISSING_IMAGE,
|
||||
ContextType.ADDON,
|
||||
reference="local_ssh",
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
|
||||
with patch.object(DockerAddon, "install") as install:
|
||||
await addon_execute_repair()
|
||||
install.assert_not_called()
|
Loading…
x
Reference in New Issue
Block a user