From 11b754102ce411066f6757194be90cdb42a3a06e Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 2 Feb 2026 11:16:31 -0500 Subject: [PATCH] Map port conflict on start error into a known error (#6445) * Map port conflict on start error into a known error * Apply suggestions from code review * Run ruff format --------- Co-authored-by: Stefan Agner --- supervisor/addons/addon.py | 10 +++++++- supervisor/const.py | 2 ++ supervisor/docker/manager.py | 15 ++++++++++++ supervisor/docker/observer.py | 4 ++-- supervisor/exceptions.py | 44 ++++++++++++++++++++++++++++++++++ supervisor/plugins/observer.py | 4 ++++ tests/addons/test_addon.py | 35 +++++++++++++++++++++++++++ tests/plugins/test_observer.py | 32 +++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 tests/plugins/test_observer.py diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 5c5ada300..c7c7944a1 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -15,7 +15,7 @@ import secrets import shutil import tarfile from tempfile import TemporaryDirectory -from typing import Any, Final +from typing import Any, Final, cast import aiohttp from awesomeversion import AwesomeVersion, AwesomeVersionCompareException @@ -70,6 +70,7 @@ from ..exceptions import ( AddonNotRunningError, AddonNotSupportedError, AddonNotSupportedWriteStdinError, + AddonPortConflict, AddonPrePostBackupCommandReturnedError, AddonsError, AddonsJobError, @@ -77,6 +78,7 @@ from ..exceptions import ( BackupRestoreUnknownError, ConfigurationFileError, DockerBuildError, + DockerContainerPortConflict, DockerError, HostAppArmorError, StoreAddonNotFoundError, @@ -1140,6 +1142,12 @@ class Addon(AddonModel): self._startup_event.clear() try: await self.instance.run() + except DockerContainerPortConflict as err: + raise AddonPortConflict( + _LOGGER.error, + name=self.slug, + port=cast(dict[str, Any], err.extra_fields)["port"], + ) from err except DockerError as err: _LOGGER.error("Could not start container for addon %s: %s", self.slug, err) self.state = AddonState.ERROR diff --git a/supervisor/const.py b/supervisor/const.py index 6bd712112..0b1c1304c 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -412,6 +412,8 @@ ROLE_ADMIN = "admin" ROLE_ALL = [ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_BACKUP, ROLE_MANAGER, ROLE_ADMIN] +OBSERVER_PORT = 4357 + class AddonBootConfig(StrEnum): """Boot mode config for the add-on.""" diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index dd27db0a9..298b9cfb2 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -42,6 +42,7 @@ from ..const import ( from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ( DockerAPIError, + DockerContainerPortConflict, DockerError, DockerNoSpaceOnDevice, DockerNotFound, @@ -69,6 +70,9 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0") DOCKER_NETWORK_HOST: Final = "host" RE_IMPORT_IMAGE_STREAM = re.compile(r"(^Loaded image ID: |^Loaded image: )(.+)$") +RE_PORT_CONFLICT_ERROR = re.compile( + r"^failed to set up container networking: .* failed to bind host port for 0.0.0.0:(\d+):\d+(?:\.\d+){3}:\d+\/\w+: address already in use$" +) @dataclass(slots=True, frozen=True) @@ -601,9 +605,20 @@ class DockerAPI(CoreSysAttributes): try: await container.start() except aiodocker.DockerError as err: + if err.status == HTTPStatus.INTERNAL_SERVER_ERROR and ( + match := RE_PORT_CONFLICT_ERROR.match(err.message) + ): + raise DockerContainerPortConflict( + _LOGGER.error, name=name or container.id, port=int(match.group(1)) + ) from err raise DockerAPIError( f"Can't start {name or container.id}: {err}", _LOGGER.error ) from err + except requests.RequestException as err: + raise DockerRequestError( + f"Dockerd connection issue for {name or container.id}: {err}", + _LOGGER.error, + ) from err return container diff --git a/supervisor/docker/observer.py b/supervisor/docker/observer.py index 9a3c5aed5..8f6344306 100644 --- a/supervisor/docker/observer.py +++ b/supervisor/docker/observer.py @@ -2,7 +2,7 @@ import logging -from ..const import DOCKER_IPV4_NETWORK_MASK, OBSERVER_DOCKER_NAME +from ..const import DOCKER_IPV4_NETWORK_MASK, OBSERVER_DOCKER_NAME, OBSERVER_PORT from ..coresys import CoreSysAttributes from ..exceptions import DockerJobError from ..jobs.const import JobConcurrency @@ -51,7 +51,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes): ENV_NETWORK_MASK: DOCKER_IPV4_NETWORK_MASK, }, mounts=[MOUNT_DOCKER], - ports={"80/tcp": 4357}, + ports={"80/tcp": OBSERVER_PORT}, oom_score_adj=-300, ) _LOGGER.info( diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index e2f230417..7d3186488 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -3,6 +3,8 @@ from collections.abc import Callable, Mapping from typing import Any +from .const import OBSERVER_PORT + MESSAGE_CHECK_SUPERVISOR_LOGS = ( "Check supervisor logs for details (check with '{logs_command}')" ) @@ -289,6 +291,18 @@ class ObserverJobError(ObserverError, PluginJobError): """Raise on job error with observer plugin.""" +class ObserverPortConflict(ObserverError, APIError): + """Raise if observer cannot start due to a port conflict.""" + + error_key = "observer_port_conflict" + message_template = "Cannot start {observer} because port {port} is already in use" + extra_fields = {"observer": "observer", "port": OBSERVER_PORT} + + def __init__(self, logger: Callable[..., None] | None = None) -> None: + """Raise & log.""" + super().__init__(None, logger) + + # Multicast @@ -393,6 +407,20 @@ class AddonNotRunningError(AddonsError, APIError): super().__init__(None, logger) +class AddonPortConflict(AddonsError, APIError): + """Raise if addon cannot start due to a port conflict.""" + + error_key = "addon_port_conflict" + message_template = "Cannot start addon {name} because port {port} is already in use" + + def __init__( + self, logger: Callable[..., None] | None = None, *, name: str, port: int + ) -> None: + """Raise & log.""" + self.extra_fields = {"name": name, "port": port} + super().__init__(None, logger) + + class AddonNotSupportedError(HassioNotSupportedError): """Addon doesn't support a function.""" @@ -866,6 +894,22 @@ class DockerNoSpaceOnDevice(DockerError): super().__init__(None, logger=logger) +class DockerContainerPortConflict(DockerError, APIError): + """Raise if docker cannot start a container due to a port conflict.""" + + error_key = "docker_container_port_conflict" + message_template = ( + "Cannot start container {name} because port {port} is already in use" + ) + + def __init__( + self, logger: Callable[..., None] | None = None, *, name: str, port: int + ) -> None: + """Raise & log.""" + self.extra_fields = {"name": name, "port": port} + super().__init__(None, logger) + + class DockerHubRateLimitExceeded(DockerError, APITooManyRequests): """Raise for docker hub rate limit exceeded error.""" diff --git a/supervisor/plugins/observer.py b/supervisor/plugins/observer.py index e599d7f34..9318dfac8 100644 --- a/supervisor/plugins/observer.py +++ b/supervisor/plugins/observer.py @@ -15,9 +15,11 @@ from ..docker.const import ContainerState from ..docker.observer import DockerObserver from ..docker.stats import DockerStats from ..exceptions import ( + DockerContainerPortConflict, DockerError, ObserverError, ObserverJobError, + ObserverPortConflict, ObserverUpdateError, PluginError, ) @@ -87,6 +89,8 @@ class PluginObserver(PluginBase): _LOGGER.info("Starting observer plugin") try: await self.instance.run() + except DockerContainerPortConflict as err: + raise ObserverPortConflict(_LOGGER.error) from err except DockerError as err: _LOGGER.error("Can't start observer plugin") raise ObserverError() from err diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 83d2d50ff..b2cb6202b 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -25,6 +25,7 @@ from supervisor.docker.const import ContainerState from supervisor.docker.manager import CommandReturn, DockerAPI from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import ( + AddonPortConflict, AddonPrePostBackupCommandReturnedError, AddonsJobError, AddonUnknownError, @@ -1002,3 +1003,37 @@ async def test_addon_disable_boot_dismisses_boot_fail( install_addon_ssh.boot = AddonBoot.MANUAL assert coresys.resolution.issues == [] assert coresys.resolution.suggestions == [] + + +@pytest.mark.usefixtures( + "container", "mock_amd64_arch_supported", "path_extern", "tmp_supervisor_data" +) +async def test_addon_start_port_conflict_error( + coresys: CoreSys, + install_addon_ssh: Addon, + caplog: pytest.LogCaptureFixture, +): + """Test port conflict error when trying to start addon.""" + install_addon_ssh.data["image"] = "test/amd64-addon-ssh" + coresys.docker.containers.create.return_value.start.side_effect = aiodocker.DockerError( + HTTPStatus.INTERNAL_SERVER_ERROR, + { + "message": "failed to set up container networking: driver failed programming external connectivity on endpoint addon_local_ssh (ea4d0fdaa72cf86f2c9199a04208e3eaf0c5a0d6fd34b3c7f4fab2daadb1f3a9): failed to bind host port for 0.0.0.0:2222:172.30.33.4:22/tcp: address already in use" + }, + ) + await install_addon_ssh.load() + + caplog.clear() + with ( + patch.object(Addon, "write_options"), + pytest.raises( + AddonPortConflict, + check=lambda exc: exc.extra_fields == {"name": "local_ssh", "port": 2222}, + ), + ): + await install_addon_ssh.start() + + assert ( + "Cannot start container addon_local_ssh because port 2222 is already in use" + in caplog.text + ) diff --git a/tests/plugins/test_observer.py b/tests/plugins/test_observer.py new file mode 100644 index 000000000..a8f182b81 --- /dev/null +++ b/tests/plugins/test_observer.py @@ -0,0 +1,32 @@ +"""Test observer plugin.""" + +from http import HTTPStatus + +import aiodocker +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import ObserverPortConflict + + +@pytest.mark.usefixtures("container", "tmp_supervisor_data", "path_extern") +async def test_observer_start_port_conflict( + coresys: CoreSys, caplog: pytest.LogCaptureFixture +): + """Test port conflict error when trying to start observer.""" + coresys.docker.containers.create.return_value.start.side_effect = aiodocker.DockerError( + HTTPStatus.INTERNAL_SERVER_ERROR, + { + "message": "failed to set up container networking: driver failed programming external connectivity on endpoint hassio_observer (ea4d0fdaa72cf86f2c9199a04208e3eaf0c5a0d6fd34b3c7f4fab2daadb1f3a9): failed to bind host port for 0.0.0.0:4357:172.30.33.4:80/tcp: address already in use" + }, + ) + await coresys.plugins.observer.load() + + caplog.clear() + with pytest.raises(ObserverPortConflict): + await coresys.plugins.observer.start() + + assert ( + "Cannot start container hassio_observer because port 4357 is already in use" + in caplog.text + )