diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 532caee6f..a2f956ec1 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -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 diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index c10d1a9d0..ef590bfc9 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -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 diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index ac28af47b..fc4bef7ff 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -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.""" diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index d383e5685..acd3ae6bc 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -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 diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index ab9fd5b3d..5ab182e36 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -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", [