1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-02-14 23:19:37 +00:00

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 <stefan@agner.ch>
This commit is contained in:
Mike Degatano
2026-02-02 11:16:31 -05:00
committed by GitHub
parent 6957341c3e
commit 11b754102c
8 changed files with 143 additions and 3 deletions

View File

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

View File

@@ -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."""

View File

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

View File

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

View File

@@ -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."""

View File

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

View File

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

View File

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