diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 038b62e7c..c10d1a9d0 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -182,18 +182,31 @@ class DockerInterface(JobGroup, ABC): """Healthcheck of instance if it has one.""" return self.meta_config.get("Healthcheck") - def _get_credentials(self, image: str) -> dict: - """Return a dictionary with credentials for docker login.""" + def _get_credentials(self, image: str) -> tuple[dict, str]: + """Return credentials for docker login and the qualified image name. + + Returns a tuple of (credentials_dict, qualified_image) where the image + is prefixed with the registry when needed. This ensures aiodocker sets + the correct ServerAddress in the X-Registry-Auth header, which Docker's + containerd image store requires to match the actual registry host. + """ credentials = {} registry = self.sys_docker.config.get_registry_for_image(image) + qualified_image = image if registry: stored = self.sys_docker.config.registries[registry] credentials[ATTR_USERNAME] = stored[ATTR_USERNAME] credentials[ATTR_PASSWORD] = stored[ATTR_PASSWORD] - # Don't include registry for Docker Hub (both official and legacy) - if registry not in (DOCKER_HUB, DOCKER_HUB_LEGACY): - credentials[ATTR_REGISTRY] = registry + credentials[ATTR_REGISTRY] = registry + + # For Docker Hub images, the image name typically lacks a registry + # prefix (e.g. "homeassistant/foo" instead of "docker.io/homeassistant/foo"). + # aiodocker derives ServerAddress from image.partition("/"), so without + # the prefix it would use the namespace ("homeassistant") as ServerAddress, + # which Docker's containerd resolver rejects as a host mismatch. + if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY): + qualified_image = f"{DOCKER_HUB}/{image}" _LOGGER.debug( "Logging in to %s as %s", @@ -201,7 +214,7 @@ class DockerInterface(JobGroup, ABC): stored[ATTR_USERNAME], ) - return credentials + return credentials, qualified_image @Job( name="docker_interface_install", @@ -288,15 +301,15 @@ class DockerInterface(JobGroup, ABC): _LOGGER.info("Downloading docker image %s with tag %s.", image, version) try: # Get credentials for private registries to pass to aiodocker - credentials = self._get_credentials(image) or None + credentials, pull_image_name = self._get_credentials(image) # Pull new image, passing credentials to aiodocker docker_image = await self.sys_docker.pull_image( current_job.uuid, - image, + pull_image_name, str(version), platform=platform, - auth=credentials, + auth=credentials or None, ) # Tag latest diff --git a/tests/docker/test_credentials.py b/tests/docker/test_credentials.py index 979ef9941..607dd75a3 100644 --- a/tests/docker/test_credentials.py +++ b/tests/docker/test_credentials.py @@ -51,10 +51,15 @@ def test_no_credentials(coresys: CoreSys, test_docker_interface: DockerInterface coresys.docker.config._data["registries"] = { DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"} } - assert not test_docker_interface._get_credentials("ghcr.io/homeassistant") - assert not test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials("ghcr.io/homeassistant") + assert not credentials + assert image == "ghcr.io/homeassistant" + + credentials, image = test_docker_interface._get_credentials( "ghcr.io/homeassistant/amd64-supervisor" ) + assert not credentials + assert image == "ghcr.io/homeassistant/amd64-supervisor" def test_no_matching_credentials( @@ -64,29 +69,37 @@ def test_no_matching_credentials( coresys.docker.config._data["registries"] = { DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"} } - assert not test_docker_interface._get_credentials("ghcr.io/homeassistant") - assert not test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials("ghcr.io/homeassistant") + assert not credentials + assert image == "ghcr.io/homeassistant" + + credentials, image = test_docker_interface._get_credentials( "ghcr.io/homeassistant/amd64-supervisor" ) + assert not credentials + assert image == "ghcr.io/homeassistant/amd64-supervisor" def test_matching_credentials(coresys: CoreSys, test_docker_interface: DockerInterface): - """Test no matching credentials.""" + """Test matching credentials.""" coresys.docker.config._data["registries"] = { "ghcr.io": {"username": "Octocat", "password": "Password1!"}, DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"}, } - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "ghcr.io/homeassistant/amd64-supervisor" ) assert credentials["registry"] == "ghcr.io" + assert image == "ghcr.io/homeassistant/amd64-supervisor" - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "homeassistant/amd64-supervisor" ) assert credentials["username"] == "Spongebob Squarepants" - assert "registry" not in credentials + assert credentials["registry"] == DOCKER_HUB + # Docker Hub images should be prefixed with docker.io/ for correct ServerAddress + assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor" def test_legacy_docker_hub_credentials( @@ -97,12 +110,12 @@ def test_legacy_docker_hub_credentials( DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password1!"}, } - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "homeassistant/amd64-supervisor" ) assert credentials["username"] == "LegacyUser" - # No registry should be included for Docker Hub - assert "registry" not in credentials + assert credentials["registry"] == DOCKER_HUB_LEGACY + assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor" def test_docker_hub_preferred_over_legacy( @@ -114,9 +127,10 @@ def test_docker_hub_preferred_over_legacy( DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password2!"}, } - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "homeassistant/amd64-supervisor" ) # docker.io should be preferred assert credentials["username"] == "NewUser" - assert "registry" not in credentials + assert credentials["registry"] == DOCKER_HUB + assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor" diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index b355abea4..ab9fd5b3d 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -14,7 +14,7 @@ from supervisor.addons.manager import Addon from supervisor.const import BusEvent, CoreState, CpuArch from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState -from supervisor.docker.interface import DOCKER_HUB, DockerInterface +from supervisor.docker.interface import DOCKER_HUB, DOCKER_HUB_LEGACY, DockerInterface from supervisor.docker.manager import PullLogEntry, PullProgressDetail from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import ( @@ -105,12 +105,22 @@ async def test_private_registry_credentials_passed_to_pull( ) # Verify credentials were passed to aiodocker - expected_auth = {"username": "testuser", "password": "testpass"} - if registry_key != DOCKER_HUB: - expected_auth["registry"] = registry_key + expected_auth = { + "username": "testuser", + "password": "testpass", + "registry": registry_key, + } + + # For Docker Hub, image should be prefixed with docker.io/ so aiodocker + # sets the correct ServerAddress in X-Registry-Auth + expected_image = ( + f"{DOCKER_HUB}/{image}" + if registry_key in (DOCKER_HUB, DOCKER_HUB_LEGACY) + else image + ) coresys.docker.images.pull.assert_called_once_with( - image, + expected_image, tag="1.2.3", platform="linux/amd64", auth=expected_auth,