Files
supervisor/tests/docker/test_credentials.py
T
a14729745c Parse image references using Docker's domain splitting logic (#7139)
* Parse image references using Docker's domain splitting logic

The unsupported container evaluation split an image reference on its first
colon to strip the tag. For a registry with a port, that colon belongs to the
port, so `myregistry:5000/app:1.0` was reduced to `myregistry` and reported as
an unsupported image on the host.

Splitting an image reference correctly needs to know where the registry domain
ends, and the pieces for that were already there but wired up in a way that
could not be reused. IMAGE_REGISTRY_REGEX is a port of Docker's DomainRegexp,
which Docker itself uses to validate a domain, not to find one. Placing it in
front of the search meant get_registry_from_image() still had to re-derive the
answer with the dot/colon/localhost checks that follow the match, and callers
that needed the rest of the reference recovered it by slicing off
len(registry) + 1 characters.

Split the two jobs apart, mirroring Docker's reference implementation:

- split_docker_domain() finds the domain the way splitDockerDomain() does, by
  cutting at the first slash and testing the candidate. It returns the
  remainder as well, so callers no longer slice by length, and it canonicalizes
  index.docker.io to docker.io, which lets stored Docker Hub credentials apply
  to references using the legacy domain.
- is_registry_domain() validates a domain against DomainRegexp, which is what
  the regex is for. Image validation keeps rejecting malformed domains such as
  ".ghcr.io" through this check.
- get_registry_from_image() stays as a wrapper for the callers that only need
  the domain.

With the domain handled, splitting off the tag is a matter of taking the last
colon that has no slash after it, which is what Docker's TagRegexp allows.
split_image_tag() does that and drops any digest, so a digest-pinned image no
longer reads as unsupported either.

Move the image reference tests to tests/docker/test_utils.py next to the code
under test.

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

* Convert remaining image reference parsing to the new helpers

Canonicalizing index.docker.io to docker.io made _get_credentials() qualify
Docker Hub images from the raw reference, so a reference already carrying a
Docker Hub domain gained a second prefix: index.docker.io/org/app was pulled as
docker.io/index.docker.io/org/app. Before the canonicalization the legacy domain
matched no configured registry and the image was pulled anonymously under its
original name, so this only surfaced now, although the same doubling already
applied to an explicit docker.io/org/app reference. Qualify from the remainder
instead, which covers references without a domain and with either Docker Hub
domain.

Three more sites split an image reference on its first colon and hit the same
problem a registry with a port causes in the unsupported container evaluation:

- The image property of DockerInterface reported myreg:5000/supervisor:1.0 as
  myreg, and a digest reference as name@sha256.
- get_latest_version() read the tag of a RepoTags entry, which for
  myreg:5000/homeassistant:2026.8.0 yielded 5000/homeassistant:2026.8.0. That
  is not a known version strategy, so every tag was skipped and the lookup
  failed with "No version found". This is reachable with a user-overridden Core
  image or a plugin image on a registry with a port.
- The Supervisor start tag repair took the image name from a RepoTags entry the
  same way, leaving myreg as the name to tag.

Also align two Docker Hub details with normalize.go: the library/ prefix for
official images now applies whenever the resolved registry is Docker Hub, not
only when the reference carried no domain, and credential lookup falls back to
the legacy hub.docker.com key for an explicit Docker Hub domain as it already
did for references without one.

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

* Cover qualifying a Docker Hub image with either registry key

The credential test only covered an image without a domain and an image with a
Docker Hub domain against credentials stored under the official docker.io key.
Parametrize over both Docker Hub domains and both registry keys, so the pull
name is asserted for a reference carrying the legacy index.docker.io domain
while credentials are stored under hub.docker.com as well.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 09:50:00 +02:00

130 lines
4.8 KiB
Python

"""Test docker login."""
import pytest
# pylint: disable=protected-access
from supervisor.coresys import CoreSys
from supervisor.docker.const import DOCKER_HUB, DOCKER_HUB_LEGACY
from supervisor.docker.interface import DockerInterface
def test_no_credentials(coresys: CoreSys, test_docker_interface: DockerInterface):
"""Test no credentials."""
coresys.docker.config._data["registries"] = {
DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"}
}
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(
coresys: CoreSys, test_docker_interface: DockerInterface
):
"""Test no matching credentials."""
coresys.docker.config._data["registries"] = {
DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"}
}
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 matching credentials."""
coresys.docker.config._data["registries"] = {
"ghcr.io": {"username": "Octocat", "password": "Password1!"},
DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"},
}
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, image = test_docker_interface._get_credentials(
"homeassistant/amd64-supervisor"
)
assert credentials["username"] == "Spongebob Squarepants"
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(
coresys: CoreSys, test_docker_interface: DockerInterface
):
"""Test legacy hub.docker.com credentials are used for Docker Hub images."""
coresys.docker.config._data["registries"] = {
DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password1!"},
}
credentials, image = test_docker_interface._get_credentials(
"homeassistant/amd64-supervisor"
)
assert credentials["username"] == "LegacyUser"
assert credentials["registry"] == DOCKER_HUB_LEGACY
assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor"
@pytest.mark.parametrize("registry_key", [DOCKER_HUB, DOCKER_HUB_LEGACY])
@pytest.mark.parametrize(
"image",
[
"homeassistant/amd64-supervisor",
f"{DOCKER_HUB}/homeassistant/amd64-supervisor",
"index.docker.io/homeassistant/amd64-supervisor",
],
)
def test_docker_hub_image_qualified_once(
coresys: CoreSys,
test_docker_interface: DockerInterface,
registry_key: str,
image: str,
):
"""Test a Docker Hub image gets exactly one docker.io prefix to pull with.
Applies to references without a domain and to both Docker Hub domains, with
credentials stored under either the official or the legacy registry key.
"""
coresys.docker.config._data["registries"] = {
registry_key: {"username": "Spongebob Squarepants", "password": "Password1!"},
}
credentials, qualified_image = test_docker_interface._get_credentials(image)
assert credentials["username"] == "Spongebob Squarepants"
assert credentials["registry"] == registry_key
assert qualified_image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor"
def test_docker_hub_preferred_over_legacy(
coresys: CoreSys, test_docker_interface: DockerInterface
):
"""Test docker.io is preferred over legacy hub.docker.com when both exist."""
coresys.docker.config._data["registries"] = {
DOCKER_HUB: {"username": "NewUser", "password": "Password1!"},
DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password2!"},
}
credentials, image = test_docker_interface._get_credentials(
"homeassistant/amd64-supervisor"
)
# docker.io should be preferred
assert credentials["username"] == "NewUser"
assert credentials["registry"] == DOCKER_HUB
assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor"