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:
Mike Degatano 2023-10-17 07:55:12 -04:00 committed by GitHub
parent ab6745bc99
commit 77fd1b4017
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 189 additions and 100 deletions

View File

@ -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
)

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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
)

View File

@ -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,

View File

@ -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
)

View File

@ -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,

View 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

View File

@ -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)

View File

@ -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()

View 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()