Skip to content

Commit

Permalink
Retry run if image missing and handle fixup
Browse files Browse the repository at this point in the history
  • Loading branch information
mdegat01 committed Oct 11, 2023
1 parent 1376a38 commit 31e8adc
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 82 deletions.
11 changes: 1 addition & 10 deletions supervisor/docker/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,24 +501,16 @@ def mounts(self) -> list[Mount]:
)
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,
Expand Down Expand Up @@ -549,7 +541,6 @@ async def run(self) -> None:
)
raise

self._meta = docker_container.attrs
_LOGGER.info(
"Starting Docker add-on %s with version %s", self.image, self.version
)
Expand Down
13 changes: 1 addition & 12 deletions supervisor/docker/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,7 @@ def cpu_rt_runtime(self) -> int | None:
)
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,
Expand All @@ -118,8 +109,6 @@ async def run(self) -> None:
},
mounts=self.mounts,
)

self._meta = docker_container.attrs
_LOGGER.info(
"Starting Audio %s with version %s - %s",
self.image,
Expand Down
13 changes: 1 addition & 12 deletions supervisor/docker/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,7 @@ def name(self) -> str:
)
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,
Expand All @@ -60,8 +51,6 @@ async def run(self) -> None:
ENV_TOKEN: self.sys_plugins.cli.supervisor_token,
},
)

self._meta = docker_container.attrs
_LOGGER.info(
"Starting CLI %s with version %s - %s",
self.image,
Expand Down
13 changes: 1 addition & 12 deletions supervisor/docker/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,7 @@ def name(self) -> str:
)
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,
Expand All @@ -65,8 +56,6 @@ async def run(self) -> None:
],
oom_score_adj=-300,
)

self._meta = docker_container.attrs
_LOGGER.info(
"Starting DNS %s with version %s - %s",
self.image,
Expand Down
13 changes: 1 addition & 12 deletions supervisor/docker/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,7 @@ def mounts(self) -> list[Mount]:
)
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,
Expand All @@ -186,8 +177,6 @@ async def run(self) -> None:
tmpfs={"/tmp": ""},
oom_score_adj=-300,
)

self._meta = docker_container.attrs
_LOGGER.info(
"Starting Home Assistant %s with version %s", self.image, self.version
)
Expand Down
25 changes: 25 additions & 0 deletions supervisor/docker/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,31 @@ async def run(self) -> None:
"""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
# Try to keep things working for user by pulling image and retrying once
capture_exception(err)
await self.install(self.version)
docker_container = await self.sys_run_in_executor(
self.sys_docker.run, self.image, **kwargs
)

# Store metadata
self._meta = docker_container.attrs

@Job(
name="docker_interface_stop",
limit=JobExecutionLimit.GROUP_ONCE,
Expand Down
13 changes: 1 addition & 12 deletions supervisor/docker/multicast.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,7 @@ def capabilities(self) -> list[Capabilities]:
)
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,
Expand All @@ -59,8 +50,6 @@ async def run(self) -> None:
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
)
13 changes: 1 addition & 12 deletions supervisor/docker/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,7 @@ def name(self) -> str:
)
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,
Expand All @@ -63,8 +54,6 @@ async def run(self) -> None:
ports={"80/tcp": 4357},
oom_score_adj=-300,
)

self._meta = docker_container.attrs
_LOGGER.info(
"Starting Observer %s with version %s - %s",
self.image,
Expand Down
57 changes: 57 additions & 0 deletions supervisor/resolution/fixups/addon_execute_repair.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions tests/docker/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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
Expand Down Expand Up @@ -223,3 +224,23 @@ 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 retries after a pull when the image is missing."""
coresys.docker.containers.create.side_effect = [NotFound("missing"), MagicMock()]
container.status = "stopped"
install_addon_ssh.data["image"] = "test_image"

with patch.object(DockerInterface, "install") as install:
await install_addon_ssh.instance.run()
install.assert_called_once_with(AwesomeVersion("9.2.1"), None, False, None)

coresys.docker.containers.create.call_count == 2
capture_exception.assert_called_once()
73 changes: 73 additions & 0 deletions tests/resolution/fixup/test_addon_execute_repair.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 31e8adc

Please sign in to comment.