mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-12-04 15:08:12 +00:00
Pass registry credentials to add-on build for private base images
When building add-ons that use a base image from a private registry, the build would fail because credentials configured via the Supervisor API were not passed to the Docker-in-Docker build container. This fix: - Adds get_docker_config_json() to generate a Docker config.json with registry credentials for the base image - Creates a temporary config file and mounts it into the build container at /root/.docker/config.json so BuildKit can authenticate when pulling the base image - Cleans up the temporary file after build completes Fixes #6354 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -12,13 +14,15 @@ from ..const import (
|
|||||||
ATTR_ARGS,
|
ATTR_ARGS,
|
||||||
ATTR_BUILD_FROM,
|
ATTR_BUILD_FROM,
|
||||||
ATTR_LABELS,
|
ATTR_LABELS,
|
||||||
|
ATTR_PASSWORD,
|
||||||
ATTR_SQUASH,
|
ATTR_SQUASH,
|
||||||
|
ATTR_USERNAME,
|
||||||
FILE_SUFFIX_CONFIGURATION,
|
FILE_SUFFIX_CONFIGURATION,
|
||||||
META_ADDON,
|
META_ADDON,
|
||||||
SOCKET_DOCKER,
|
SOCKET_DOCKER,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
from ..docker.interface import MAP_ARCH
|
from ..docker.interface import DOCKER_HUB, IMAGE_WITH_HOST, MAP_ARCH
|
||||||
from ..exceptions import ConfigurationFileError, HassioArchNotFound
|
from ..exceptions import ConfigurationFileError, HassioArchNotFound
|
||||||
from ..utils.common import FileConfiguration, find_one_filetype
|
from ..utils.common import FileConfiguration, find_one_filetype
|
||||||
from .validate import SCHEMA_BUILD_CONFIG
|
from .validate import SCHEMA_BUILD_CONFIG
|
||||||
@@ -122,8 +126,49 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
|||||||
except HassioArchNotFound:
|
except HassioArchNotFound:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_docker_config_json(self) -> str | None:
|
||||||
|
"""Generate Docker config.json content with registry credentials for base image.
|
||||||
|
|
||||||
|
Returns JSON string with credentials, or None if no credentials needed.
|
||||||
|
Must be run in executor.
|
||||||
|
"""
|
||||||
|
if not self.sys_docker.config.registries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_image = self.base_image
|
||||||
|
registry = None
|
||||||
|
|
||||||
|
# Check if base image uses a custom registry
|
||||||
|
matcher = IMAGE_WITH_HOST.match(base_image)
|
||||||
|
if matcher:
|
||||||
|
if matcher.group(1) in self.sys_docker.config.registries:
|
||||||
|
registry = matcher.group(1)
|
||||||
|
# If no match, check for Docker Hub credentials
|
||||||
|
elif DOCKER_HUB in self.sys_docker.config.registries:
|
||||||
|
registry = DOCKER_HUB
|
||||||
|
|
||||||
|
if not registry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stored = self.sys_docker.config.registries[registry]
|
||||||
|
username = stored[ATTR_USERNAME]
|
||||||
|
password = stored[ATTR_PASSWORD]
|
||||||
|
|
||||||
|
# Docker config.json uses base64-encoded "username:password" for auth
|
||||||
|
auth_string = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
# Use the actual registry URL for the key
|
||||||
|
# Docker Hub uses "https://index.docker.io/v1/" as the key
|
||||||
|
registry_key = (
|
||||||
|
"https://index.docker.io/v1/" if registry == DOCKER_HUB else registry
|
||||||
|
)
|
||||||
|
|
||||||
|
config = {"auths": {registry_key: {"auth": auth_string}}}
|
||||||
|
|
||||||
|
return json.dumps(config)
|
||||||
|
|
||||||
def get_docker_args(
|
def get_docker_args(
|
||||||
self, version: AwesomeVersion, image_tag: str
|
self, version: AwesomeVersion, image_tag: str, docker_config_path: Path | None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a dict with Docker run args."""
|
"""Create a dict with Docker run args."""
|
||||||
dockerfile_path = self.get_dockerfile().relative_to(self.addon.path_location)
|
dockerfile_path = self.get_dockerfile().relative_to(self.addon.path_location)
|
||||||
@@ -172,12 +217,24 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
|||||||
self.addon.path_location
|
self.addon.path_location
|
||||||
)
|
)
|
||||||
|
|
||||||
|
volumes = {
|
||||||
|
SOCKET_DOCKER: {"bind": "/var/run/docker.sock", "mode": "rw"},
|
||||||
|
addon_extern_path: {"bind": "/addon", "mode": "ro"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mount Docker config with registry credentials if available
|
||||||
|
if docker_config_path:
|
||||||
|
docker_config_extern_path = self.sys_config.local_to_extern_path(
|
||||||
|
docker_config_path
|
||||||
|
)
|
||||||
|
volumes[docker_config_extern_path] = {
|
||||||
|
"bind": "/root/.docker/config.json",
|
||||||
|
"mode": "ro",
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"command": build_cmd,
|
"command": build_cmd,
|
||||||
"volumes": {
|
"volumes": volumes,
|
||||||
SOCKET_DOCKER: {"bind": "/var/run/docker.sock", "mode": "rw"},
|
|
||||||
addon_extern_path: {"bind": "/addon", "mode": "ro"},
|
|
||||||
},
|
|
||||||
"working_dir": "/addon",
|
"working_dir": "/addon",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from ipaddress import IPv4Address
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
import aiodocker
|
import aiodocker
|
||||||
@@ -705,12 +706,36 @@ class DockerAddon(DockerInterface):
|
|||||||
with suppress(docker.errors.NotFound):
|
with suppress(docker.errors.NotFound):
|
||||||
self.sys_docker.containers.get(builder_name).remove(force=True, v=True)
|
self.sys_docker.containers.get(builder_name).remove(force=True, v=True)
|
||||||
|
|
||||||
result = self.sys_docker.run_command(
|
# Generate Docker config with registry credentials for base image if needed
|
||||||
ADDON_BUILDER_IMAGE,
|
docker_config_path: Path | None = None
|
||||||
version=builder_version_tag,
|
docker_config_content = build_env.get_docker_config_json()
|
||||||
name=builder_name,
|
temp_dir: tempfile.TemporaryDirectory | None = None
|
||||||
**build_env.get_docker_args(version, addon_image_tag),
|
|
||||||
)
|
try:
|
||||||
|
if docker_config_content:
|
||||||
|
# Create temporary directory for docker config
|
||||||
|
temp_dir = tempfile.TemporaryDirectory(
|
||||||
|
prefix="hassio_build_", dir=self.sys_config.path_tmp
|
||||||
|
)
|
||||||
|
docker_config_path = Path(temp_dir.name) / "config.json"
|
||||||
|
docker_config_path.write_text(docker_config_content)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Created temporary Docker config for build at %s",
|
||||||
|
docker_config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.sys_docker.run_command(
|
||||||
|
ADDON_BUILDER_IMAGE,
|
||||||
|
version=builder_version_tag,
|
||||||
|
name=builder_name,
|
||||||
|
**build_env.get_docker_args(
|
||||||
|
version, addon_image_tag, docker_config_path
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Clean up temporary directory
|
||||||
|
if temp_dir:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
logs = result.output.decode("utf-8")
|
logs = result.output.decode("utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Test addon build."""
|
"""Test addon build."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import PropertyMock, patch
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
@@ -7,6 +10,7 @@ from awesomeversion import AwesomeVersion
|
|||||||
from supervisor.addons.addon import Addon
|
from supervisor.addons.addon import Addon
|
||||||
from supervisor.addons.build import AddonBuild
|
from supervisor.addons.build import AddonBuild
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
|
from supervisor.docker.interface import DOCKER_HUB
|
||||||
|
|
||||||
from tests.common import is_in_list
|
from tests.common import is_in_list
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ async def test_platform_set(coresys: CoreSys, install_addon_ssh: Addon):
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
args = await coresys.run_in_executor(
|
args = await coresys.run_in_executor(
|
||||||
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest"
|
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest", None
|
||||||
)
|
)
|
||||||
|
|
||||||
assert is_in_list(["--platform", "linux/amd64"], args["command"])
|
assert is_in_list(["--platform", "linux/amd64"], args["command"])
|
||||||
@@ -53,7 +57,7 @@ async def test_dockerfile_evaluation(coresys: CoreSys, install_addon_ssh: Addon)
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
args = await coresys.run_in_executor(
|
args = await coresys.run_in_executor(
|
||||||
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest"
|
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest", None
|
||||||
)
|
)
|
||||||
|
|
||||||
assert is_in_list(["--file", "Dockerfile"], args["command"])
|
assert is_in_list(["--file", "Dockerfile"], args["command"])
|
||||||
@@ -81,7 +85,7 @@ async def test_dockerfile_evaluation_arch(coresys: CoreSys, install_addon_ssh: A
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
args = await coresys.run_in_executor(
|
args = await coresys.run_in_executor(
|
||||||
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest"
|
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest", None
|
||||||
)
|
)
|
||||||
|
|
||||||
assert is_in_list(["--file", "Dockerfile.aarch64"], args["command"])
|
assert is_in_list(["--file", "Dockerfile.aarch64"], args["command"])
|
||||||
@@ -117,3 +121,155 @@ async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
assert not await build.is_valid()
|
assert not await build.is_valid()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_config_no_registries(coresys: CoreSys, install_addon_ssh: Addon):
|
||||||
|
"""Test docker config generation when no registries configured."""
|
||||||
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
|
|
||||||
|
# No registries configured by default
|
||||||
|
assert build.get_docker_config_json() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_config_no_matching_registry(
|
||||||
|
coresys: CoreSys, install_addon_ssh: Addon
|
||||||
|
):
|
||||||
|
"""Test docker config generation when registry doesn't match base image."""
|
||||||
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
|
|
||||||
|
# Configure a registry that doesn't match the base image
|
||||||
|
coresys.docker.config._data["registries"] = {
|
||||||
|
"some.other.registry": {"username": "user", "password": "pass"}
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "supported", new=PropertyMock(return_value=["amd64"])
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Base image is ghcr.io/home-assistant/... which doesn't match
|
||||||
|
assert build.get_docker_config_json() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_config_matching_registry(
|
||||||
|
coresys: CoreSys, install_addon_ssh: Addon
|
||||||
|
):
|
||||||
|
"""Test docker config generation when registry matches base image."""
|
||||||
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
|
|
||||||
|
# Configure ghcr.io registry which matches the default base image
|
||||||
|
coresys.docker.config._data["registries"] = {
|
||||||
|
"ghcr.io": {"username": "testuser", "password": "testpass"}
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "supported", new=PropertyMock(return_value=["amd64"])
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
config_json = build.get_docker_config_json()
|
||||||
|
assert config_json is not None
|
||||||
|
|
||||||
|
config = json.loads(config_json)
|
||||||
|
assert "auths" in config
|
||||||
|
assert "ghcr.io" in config["auths"]
|
||||||
|
|
||||||
|
# Verify base64-encoded credentials
|
||||||
|
expected_auth = base64.b64encode(b"testuser:testpass").decode()
|
||||||
|
assert config["auths"]["ghcr.io"]["auth"] == expected_auth
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_config_docker_hub(coresys: CoreSys, install_addon_ssh: Addon):
|
||||||
|
"""Test docker config generation for Docker Hub registry."""
|
||||||
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
|
|
||||||
|
# Configure Docker Hub registry
|
||||||
|
coresys.docker.config._data["registries"] = {
|
||||||
|
DOCKER_HUB: {"username": "hubuser", "password": "hubpass"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock base_image to return a Docker Hub image (no registry prefix)
|
||||||
|
with patch.object(
|
||||||
|
type(build),
|
||||||
|
"base_image",
|
||||||
|
new=PropertyMock(return_value="library/alpine:latest"),
|
||||||
|
):
|
||||||
|
config_json = build.get_docker_config_json()
|
||||||
|
assert config_json is not None
|
||||||
|
|
||||||
|
config = json.loads(config_json)
|
||||||
|
# Docker Hub uses special URL as key
|
||||||
|
assert "https://index.docker.io/v1/" in config["auths"]
|
||||||
|
|
||||||
|
expected_auth = base64.b64encode(b"hubuser:hubpass").decode()
|
||||||
|
assert config["auths"]["https://index.docker.io/v1/"]["auth"] == expected_auth
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_args_with_config_path(coresys: CoreSys, install_addon_ssh: Addon):
|
||||||
|
"""Test docker args include config volume when path provided."""
|
||||||
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "supported", new=PropertyMock(return_value=["amd64"])
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
type(coresys.config),
|
||||||
|
"local_to_extern_path",
|
||||||
|
side_effect=lambda p: f"/extern{p}",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
config_path = Path("/data/supervisor/tmp/config.json")
|
||||||
|
args = await coresys.run_in_executor(
|
||||||
|
build.get_docker_args,
|
||||||
|
AwesomeVersion("latest"),
|
||||||
|
"test-image:latest",
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that config is mounted
|
||||||
|
assert "/extern/data/supervisor/tmp/config.json" in args["volumes"]
|
||||||
|
assert (
|
||||||
|
args["volumes"]["/extern/data/supervisor/tmp/config.json"]["bind"]
|
||||||
|
== "/root/.docker/config.json"
|
||||||
|
)
|
||||||
|
assert args["volumes"]["/extern/data/supervisor/tmp/config.json"]["mode"] == "ro"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_args_without_config_path(
|
||||||
|
coresys: CoreSys, install_addon_ssh: Addon
|
||||||
|
):
|
||||||
|
"""Test docker args don't include config volume when no path provided."""
|
||||||
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "supported", new=PropertyMock(return_value=["amd64"])
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
type(coresys.config),
|
||||||
|
"local_to_extern_path",
|
||||||
|
return_value="/addon/path/on/host",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
args = await coresys.run_in_executor(
|
||||||
|
build.get_docker_args, AwesomeVersion("latest"), "test-image:latest", None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only docker socket and addon path should be mounted
|
||||||
|
assert len(args["volumes"]) == 2
|
||||||
|
# Verify no docker config mount
|
||||||
|
for bind in args["volumes"].values():
|
||||||
|
assert bind["bind"] != "/root/.docker/config.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user