mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-04-02 00:07:16 +01:00
Fix Docker Hub registry auth for containerd image store (#6677)
aiodocker derives ServerAddress for X-Registry-Auth by doing
image.partition("/"). For Docker Hub images like
"homeassistant/amd64-supervisor", this extracts "homeassistant"
(the namespace) instead of "docker.io" (the registry).
With the classic graphdriver image store, ServerAddress was never
checked and credentials were sent regardless. With the containerd
image store (default since Docker v29 / HAOS 15), the resolver
compares ServerAddress against the actual registry host and silently
drops credentials on mismatch, falling back to anonymous access.
Fix by prefixing Docker Hub images with "docker.io/" when registry
credentials are configured, so aiodocker sets ServerAddress correctly.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user