diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 05da47c44cc..009fa549b84 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -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, @@ -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 ) diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py index eeb34249d39..003f8f6b0bb 100644 --- a/supervisor/docker/audio.py +++ b/supervisor/docker/audio.py @@ -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, @@ -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, diff --git a/supervisor/docker/cli.py b/supervisor/docker/cli.py index 7f0fe9a7a36..ca0eec0865d 100644 --- a/supervisor/docker/cli.py +++ b/supervisor/docker/cli.py @@ -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, @@ -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, diff --git a/supervisor/docker/dns.py b/supervisor/docker/dns.py index 56fd07082a4..b357951ca03 100644 --- a/supervisor/docker/dns.py +++ b/supervisor/docker/dns.py @@ -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, @@ -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, diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index c96f6c7600b..ea5507ce592 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -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, @@ -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 ) diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index af63b6d3580..aba8bb77384 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -377,6 +377,27 @@ 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 + capture_exception(err) + raise + + # Store metadata + self._meta = docker_container.attrs + @Job( name="docker_interface_stop", limit=JobExecutionLimit.GROUP_ONCE, diff --git a/supervisor/docker/multicast.py b/supervisor/docker/multicast.py index 4c8947f885e..1439cd40c28 100644 --- a/supervisor/docker/multicast.py +++ b/supervisor/docker/multicast.py @@ -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, @@ -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 ) diff --git a/supervisor/docker/observer.py b/supervisor/docker/observer.py index 1f0eeaa3cd8..67ec4d02959 100644 --- a/supervisor/docker/observer.py +++ b/supervisor/docker/observer.py @@ -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, @@ -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, diff --git a/supervisor/resolution/fixups/addon_execute_repair.py b/supervisor/resolution/fixups/addon_execute_repair.py new file mode 100644 index 00000000000..e497a98831c --- /dev/null +++ b/supervisor/resolution/fixups/addon_execute_repair.py @@ -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 diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index bd284f49db8..652d2e9f1f7 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -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) diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index 4132564af26..a9222de5457 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -10,12 +10,18 @@ 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() diff --git a/tests/resolution/fixup/test_addon_execute_repair.py b/tests/resolution/fixup/test_addon_execute_repair.py new file mode 100644 index 00000000000..ea3ae5a0b5f --- /dev/null +++ b/tests/resolution/fixup/test_addon_execute_repair.py @@ -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()