1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-04-02 08:12:47 +01:00

Add specific error message for registry authentication failures (#6678)

* Add specific error message for registry authentication failures

When a Docker image pull fails with 401 Unauthorized and registry
credentials are configured, raise DockerRegistryAuthError instead of
a generic DockerError. This surfaces a clear message to the user
("Docker registry authentication failed for <registry>. Check your
registry credentials") instead of "An unknown error occurred with
addon <name>".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tests for registry authentication error handling

Test that a 401 during image pull raises DockerRegistryAuthError when
credentials are configured, and falls back to generic DockerError
when no credentials are present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tests for addon install/update/rebuild auth failure handling

Test that DockerRegistryAuthError propagates correctly through
addon install, update, and rebuild paths without being wrapped
in a generic AddonUnknownError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-03-31 09:29:49 +02:00
committed by GitHub
parent e630ec1ac4
commit a4a17a70a5
5 changed files with 148 additions and 0 deletions

View File

@@ -81,6 +81,7 @@ from ..exceptions import (
DockerBuildError,
DockerContainerPortConflict,
DockerError,
DockerRegistryAuthError,
HostAppArmorError,
StoreAddonNotFoundError,
)
@@ -821,6 +822,9 @@ class Addon(AddonModel):
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
await self.sys_addons.data.uninstall(self)
raise AddonBuildFailedUnknownError(addon=self.slug) from err
except DockerRegistryAuthError:
await self.sys_addons.data.uninstall(self)
raise
except DockerError as err:
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
await self.sys_addons.data.uninstall(self)
@@ -925,6 +929,8 @@ class Addon(AddonModel):
except DockerBuildError as err:
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
raise AddonBuildFailedUnknownError(addon=self.slug) from err
except DockerRegistryAuthError:
raise
except DockerError as err:
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
raise AddonUnknownError(addon=self.slug) from err
@@ -985,6 +991,8 @@ class Addon(AddonModel):
except DockerBuildError as err:
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
raise AddonBuildFailedUnknownError(addon=self.slug) from err
except DockerRegistryAuthError:
raise
except DockerError as err:
_LOGGER.error(
"Could not pull image to update addon %s: %s", self.slug, err

View File

@@ -33,6 +33,7 @@ from ..exceptions import (
DockerHubRateLimitExceeded,
DockerJobError,
DockerNotFound,
DockerRegistryAuthError,
)
from ..jobs.const import JOB_GROUP_DOCKER_INTERFACE, JobConcurrency
from ..jobs.decorator import Job
@@ -328,6 +329,10 @@ class DockerInterface(JobGroup, ABC):
suggestions=[SuggestionType.REGISTRY_LOGIN],
)
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
if err.status == HTTPStatus.UNAUTHORIZED and credentials:
raise DockerRegistryAuthError(
_LOGGER.error, registry=credentials[ATTR_REGISTRY]
) from err
await async_capture_exception(err)
raise DockerError(
f"Can't install {image}:{version!s}: {err}", _LOGGER.error

View File

@@ -894,6 +894,23 @@ class DockerContainerPortConflict(DockerError, APIError):
super().__init__(None, logger)
class DockerRegistryAuthError(DockerError, APIError):
"""Raise when Docker registry authentication fails."""
error_key = "docker_registry_auth_error"
message_template = (
"Docker registry authentication failed for {registry}. "
"Check your registry credentials"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, registry: str
) -> None:
"""Raise & log."""
self.extra_fields = {"registry": registry}
super().__init__(None, logger=logger)
class DockerHubRateLimitExceeded(DockerError, APITooManyRequests):
"""Raise for docker hub rate limit exceeded error."""

View File

@@ -30,6 +30,7 @@ from supervisor.exceptions import (
AddonsJobError,
AddonUnknownError,
AudioUpdateError,
DockerRegistryAuthError,
HassioError,
)
from supervisor.hardware.helper import HwHelper
@@ -757,6 +758,67 @@ async def test_local_example_install(coresys: CoreSys, tmp_supervisor_data: Path
assert data_dir.is_dir()
@pytest.mark.usefixtures("test_repository", "tmp_supervisor_data")
async def test_addon_install_auth_failure(coresys: CoreSys):
"""Test addon install raises DockerRegistryAuthError on 401 with credentials."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
# Configure bad registry credentials
coresys.docker.config._data["registries"] = { # pylint: disable=protected-access
"docker.io": {"username": "baduser", "password": "badpass"}
}
with (
patch.object(
DockerAddon,
"install",
side_effect=DockerRegistryAuthError(registry="docker.io"),
),
pytest.raises(DockerRegistryAuthError),
):
await coresys.addons.install("local_example")
# Verify addon data was cleaned up
assert "local_example" not in coresys.addons.local
@pytest.mark.usefixtures("tmp_supervisor_data")
async def test_addon_update_auth_failure(
coresys: CoreSys, install_addon_example: Addon
):
"""Test addon update raises DockerRegistryAuthError on 401 with credentials."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
with (
patch.object(
DockerAddon,
"update",
side_effect=DockerRegistryAuthError(registry="docker.io"),
),
pytest.raises(DockerRegistryAuthError),
):
await install_addon_example.update()
@pytest.mark.usefixtures("tmp_supervisor_data")
async def test_addon_rebuild_auth_failure(
coresys: CoreSys, install_addon_example: Addon
):
"""Test addon rebuild raises DockerRegistryAuthError on 401 with credentials."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
with (
patch.object(DockerAddon, "remove"),
patch.object(
DockerAddon,
"install",
side_effect=DockerRegistryAuthError(registry="docker.io"),
),
pytest.raises(DockerRegistryAuthError),
):
await install_addon_example.rebuild()
@pytest.mark.usefixtures("coresys", "path_extern")
async def test_local_example_start(
tmp_supervisor_data: Path, install_addon_example: Addon

View File

@@ -22,6 +22,7 @@ from supervisor.exceptions import (
DockerError,
DockerNoSpaceOnDevice,
DockerNotFound,
DockerRegistryAuthError,
)
from supervisor.homeassistant.const import WSEvent, WSType
from supervisor.jobs import ChildJobSyncFilter, JobSchedulerOptions, SupervisorJob
@@ -129,6 +130,61 @@ async def test_private_registry_credentials_passed_to_pull(
)
async def test_pull_401_with_credentials_raises_auth_error(
coresys: CoreSys,
test_docker_interface: DockerInterface,
):
"""Test that a 401 during pull with credentials raises DockerRegistryAuthError."""
image = "homeassistant/amd64-supervisor"
# Configure registry credentials
coresys.docker.config._data["registries"] = { # pylint: disable=protected-access
"docker.io": {"username": "baduser", "password": "badpass"}
}
# Make pull raise 401
coresys.docker.images.pull.side_effect = aiodocker.DockerError(
HTTPStatus.UNAUTHORIZED,
{"message": "unauthorized: incorrect username or password"},
)
with (
patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value="amd64")
),
pytest.raises(DockerRegistryAuthError, match="docker.io"),
):
await test_docker_interface.install(
AwesomeVersion("1.2.3"), image, arch=CpuArch.AMD64
)
async def test_pull_401_without_credentials_raises_docker_error(
coresys: CoreSys,
test_docker_interface: DockerInterface,
):
"""Test that a 401 during pull without credentials raises generic DockerError."""
image = "homeassistant/amd64-supervisor"
# No registry credentials configured
# Make pull raise 401
coresys.docker.images.pull.side_effect = aiodocker.DockerError(
HTTPStatus.UNAUTHORIZED,
{"message": "unauthorized: incorrect username or password"},
)
with (
patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value="amd64")
),
pytest.raises(DockerError, match="Can't install"),
):
await test_docker_interface.install(
AwesomeVersion("1.2.3"), image, arch=CpuArch.AMD64
)
@pytest.mark.parametrize(
"attrs,expected",
[