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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user