1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-04-02 08:12:47 +01:00

Make app builds work without build.yaml

The builds are moving build configuration into the Dockerfile itself (base
image defaults via `ARG`, labels via `LABEL`). This change makes `build.yaml`
optional for local app builds while preserving backward compatibility for apps
that still define it.

Key changes:
* Track whether `build.yaml` was found, log a warning if it is.
* Skip `BUILD_FROM` build arg when no build file exists, letting the
  Dockerfile's own `ARG BUILD_FROM=...` default take effect.
* Always include all configured registry credentials in docker config instead
  of matching only the base image's registry.
* Only set `io.hass.name` and `io.hass.description` labels when non-empty, as
  they could be defined in the Dockerfile directly.
* Log a deprecation warning when `build.yaml` is present.

Refs home-assistant/epics#33
This commit is contained in:
Jan Čermák
2026-04-01 15:20:16 +02:00
parent 31636fe310
commit dbb29e93fb
4 changed files with 297 additions and 104 deletions

View File

@@ -19,7 +19,13 @@ from ..const import (
ATTR_SQUASH,
ATTR_USERNAME,
FILE_SUFFIX_CONFIGURATION,
META_ADDON,
LABEL_ARCH,
LABEL_DESCRIPTION,
LABEL_NAME,
LABEL_TYPE,
LABEL_URL,
LABEL_VERSION,
META_APP,
SOCKET_DOCKER,
CpuArch,
)
@@ -48,20 +54,29 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Initialize Supervisor add-on builder."""
self.coresys: CoreSys = coresys
self.addon = addon
self._has_build_file: bool = False
# Search for build file later in executor
super().__init__(None, SCHEMA_BUILD_CONFIG)
@property
def has_build_file(self) -> bool:
"""Return True if a build configuration file was found on disk."""
return self._has_build_file
def _get_build_file(self) -> Path:
"""Get build file.
Must be run in executor.
"""
try:
return find_one_filetype(
result = find_one_filetype(
self.addon.path_location, "build", FILE_SUFFIX_CONFIGURATION
)
self._has_build_file = True
return result
except ConfigurationFileError:
self._has_build_file = False
return self.addon.path_location / "build.json"
async def read_data(self) -> None:
@@ -81,10 +96,12 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
return self.sys_arch.match([self.addon.arch])
@property
def base_image(self) -> str:
"""Return base image for this add-on."""
def base_image(self) -> str | None:
"""Return base image for this add-on, or None to use Dockerfile default."""
if not self._data[ATTR_BUILD_FROM]:
return f"ghcr.io/home-assistant/{self.arch!s}-base:latest"
if self._has_build_file:
return "ghcr.io/home-assistant/base:latest"
return None
if isinstance(self._data[ATTR_BUILD_FROM], str):
return self._data[ATTR_BUILD_FROM]
@@ -144,43 +161,33 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
system_arch_list=[arch.value for arch in self.sys_arch.supported],
) from None
def _registry_key(self, registry: str) -> str:
"""Return the Docker config.json key for a registry."""
if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY):
return "https://index.docker.io/v1/"
return registry
def _registry_auth(self, registry: str) -> str:
"""Return base64-encoded auth string for a registry."""
stored = self.sys_docker.config.registries[registry]
return base64.b64encode(
f"{stored[ATTR_USERNAME]}:{stored[ATTR_PASSWORD]}".encode()
).decode()
def get_docker_config_json(self) -> str | None:
"""Generate Docker config.json content with registry credentials for base image.
Returns a JSON string with registry credentials for the base image's registry,
or None if no matching registry is configured.
Raises:
HassioArchNotFound: If the add-on is not supported on the current architecture.
"""Generate Docker config.json content with all configured registry credentials.
Returns a JSON string with registry credentials, or None if no registries
are configured.
"""
# Early return before accessing base_image to avoid unnecessary arch lookup
if not self.sys_docker.config.registries:
return None
registry = self.sys_docker.config.get_registry_for_image(self.base_image)
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
# Support both docker.io (official) and hub.docker.com (legacy)
registry_key = (
"https://index.docker.io/v1/"
if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY)
else registry
)
config = {"auths": {registry_key: {"auth": auth_string}}}
return json.dumps(config)
auths = {
self._registry_key(registry): {"auth": self._registry_auth(registry)}
for registry in self.sys_docker.config.registries
}
return json.dumps({"auths": auths})
def get_docker_args(
self, version: AwesomeVersion, image_tag: str, docker_config_path: Path | None
@@ -203,27 +210,35 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
]
labels = {
"io.hass.version": version,
"io.hass.arch": self.arch,
"io.hass.type": META_ADDON,
"io.hass.name": self._fix_label("name"),
"io.hass.description": self._fix_label("description"),
LABEL_VERSION: version,
LABEL_ARCH: self.arch,
LABEL_TYPE: META_APP,
**self.additional_labels,
}
# Set name only if non-empty, could have been set in Dockerfile
if name := self._fix_label("name"):
labels[LABEL_NAME] = name
# Set description only if non-empty, could have been set in Dockerfile
if description := self._fix_label("description"):
labels[LABEL_DESCRIPTION] = description
if self.addon.url:
labels["io.hass.url"] = self.addon.url
labels[LABEL_URL] = self.addon.url
for key, value in labels.items():
build_cmd.extend(["--label", f"{key}={value}"])
build_args = {
"BUILD_FROM": self.base_image,
"BUILD_VERSION": version,
"BUILD_ARCH": self.arch,
**self.additional_args,
}
if self.base_image is not None:
build_args["BUILD_FROM"] = self.base_image
for key, value in build_args.items():
build_cmd.extend(["--build-arg", f"{key}={value}"])

View File

@@ -65,11 +65,15 @@ DOCKER_CPU_RUNTIME_ALLOCATION = int(DOCKER_CPU_RUNTIME_TOTAL / 5)
DNS_SUFFIX = "local.hass.io"
LABEL_ARCH = "io.hass.arch"
LABEL_DESCRIPTION = "io.hass.description"
LABEL_MACHINE = "io.hass.machine"
LABEL_NAME = "io.hass.name"
LABEL_TYPE = "io.hass.type"
LABEL_URL = "io.hass.url"
LABEL_VERSION = "io.hass.version"
META_ADDON = "addon"
META_ADDON = "addon" # legacy label for app
META_APP = "app"
META_HOMEASSISTANT = "homeassistant"
META_SUPERVISOR = "supervisor"

View File

@@ -684,6 +684,13 @@ class DockerAddon(DockerInterface):
# Check if the build environment is valid, raises if not
await build_env.is_valid()
if build_env.has_build_file:
_LOGGER.warning(
"Add-on %s uses build.yaml which is deprecated. "
"Move build parameters into the Dockerfile directly.",
self.addon.slug,
)
_LOGGER.info("Starting build for %s:%s", self.image, version)
if build_env.squash:
_LOGGER.warning(

View File

@@ -17,6 +17,18 @@ from supervisor.exceptions import AddonBuildDockerfileMissingError
from tests.common import is_in_list
def _is_build_arg_in_command(command: list[str], arg_name: str) -> bool:
"""Check if a build arg is in docker command."""
return f"--build-arg {arg_name}=" in " ".join(command)
def _is_label_in_command(
command: list[str], label_name: str, label_value: str = ""
) -> bool:
"""Check if a label is in docker command."""
return f"--label {label_name}={label_value}" in " ".join(command)
async def test_platform_set(coresys: CoreSys, install_addon_ssh: Addon):
"""Test platform set in container build args."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
@@ -134,87 +146,47 @@ async def test_docker_config_no_registries(coresys: CoreSys, install_addon_ssh:
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."""
async def test_docker_config_all_registries(coresys: CoreSys, install_addon_ssh: Addon):
"""Test docker config includes all configured registries."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
# Configure a registry that doesn't match the base image
# pylint: disable-next=protected-access
coresys.docker.config._data["registries"] = {
"some.other.registry": {"username": "user", "password": "pass"}
"ghcr.io": {"username": "testuser", "password": "testpass"},
"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
config_json = build.get_docker_config_json()
assert config_json is not None
config = json.loads(config_json)
assert "ghcr.io" in config["auths"]
assert "some.other.registry" in config["auths"]
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()
expected_ghcr = base64.b64encode(b"testuser:testpass").decode()
assert config["auths"]["ghcr.io"]["auth"] == expected_ghcr
# Configure ghcr.io registry which matches the default base image
# pylint: disable-next=protected-access
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
expected_other = base64.b64encode(b"user:pass").decode()
assert config["auths"]["some.other.registry"]["auth"] == expected_other
async def test_docker_config_docker_hub(coresys: CoreSys, install_addon_ssh: Addon):
"""Test docker config generation for Docker Hub registry."""
"""Test docker config uses special URL key for Docker Hub."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
# Configure Docker Hub registry
# pylint: disable-next=protected-access
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 = 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"]
config = json.loads(config_json)
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
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):
@@ -280,3 +252,198 @@ async def test_docker_args_without_config_path(
# Verify no docker config mount
for mount in args["mounts"]:
assert mount.target != "/root/.docker/config.json"
async def test_has_build_file_true(coresys: CoreSys, install_addon_ssh: Addon):
"""Test has_build_file is True when build.yaml exists."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
assert build.has_build_file is True
async def test_has_build_file_false(
coresys: CoreSys, install_addon_ssh: Addon, tmp_path: Path
):
"""Test has_build_file is False when no build file exists."""
# Create a minimal addon directory without build.yaml
dockerfile = tmp_path / "Dockerfile"
dockerfile.write_text("ARG BUILD_FROM=ghcr.io/home-assistant/base:latest\n")
with patch.object(
type(install_addon_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
):
build = await AddonBuild(coresys, install_addon_ssh).load_config()
assert build.has_build_file is False
async def test_no_build_yaml_base_image_none(
coresys: CoreSys, install_addon_ssh: Addon, tmp_path: Path
):
"""Test base_image is None when no build file exists."""
dockerfile = tmp_path / "Dockerfile"
dockerfile.write_text("ARG BUILD_FROM=ghcr.io/home-assistant/base:latest\n")
with patch.object(
type(install_addon_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
):
build = await AddonBuild(coresys, install_addon_ssh).load_config()
assert build.base_image is None
async def test_no_build_yaml_no_build_from_arg(
coresys: CoreSys, install_addon_ssh: Addon, tmp_path: Path
):
"""Test BUILD_FROM is not in docker args when no build file exists."""
dockerfile = tmp_path / "Dockerfile"
dockerfile.write_text("ARG BUILD_FROM=ghcr.io/home-assistant/base:latest\n")
with (
patch.object(
type(install_addon_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
),
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=PurePath("/addon/path/on/host"),
),
):
build = await AddonBuild(coresys, install_addon_ssh).load_config()
args = await coresys.run_in_executor(
build.get_docker_args, AwesomeVersion("1.0.0"), "test-image:1.0.0", None
)
assert not _is_build_arg_in_command(args["command"], "BUILD_FROM")
assert _is_build_arg_in_command(args["command"], "BUILD_VERSION")
assert _is_build_arg_in_command(args["command"], "BUILD_ARCH")
async def test_build_yaml_passes_build_from(coresys: CoreSys, install_addon_ssh: Addon):
"""Test BUILD_FROM is in docker args when build.yaml exists."""
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=PurePath("/addon/path/on/host"),
),
):
args = await coresys.run_in_executor(
build.get_docker_args, AwesomeVersion("1.0.0"), "test-image:1.0.0", None
)
assert _is_build_arg_in_command(args["command"], "BUILD_FROM")
assert _is_build_arg_in_command(args["command"], "BUILD_VERSION")
assert _is_build_arg_in_command(args["command"], "BUILD_ARCH")
async def test_no_build_yaml_docker_config_includes_registries(
coresys: CoreSys, install_addon_ssh: Addon, tmp_path: Path
):
"""Test registries are included in docker config even without build file."""
dockerfile = tmp_path / "Dockerfile"
dockerfile.write_text("ARG BUILD_FROM=ghcr.io/home-assistant/base:latest\n")
# pylint: disable-next=protected-access
coresys.docker.config._data["registries"] = {
"ghcr.io": {"username": "ghcr_user", "password": "ghcr_pass"},
}
with patch.object(
type(install_addon_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
):
build = await AddonBuild(coresys, install_addon_ssh).load_config()
config_json = build.get_docker_config_json()
assert config_json is not None
config = json.loads(config_json)
assert "ghcr.io" in config["auths"]
async def test_labels_include_name_and_description(
coresys: CoreSys, install_addon_ssh: Addon
):
"""Test name and description labels are included when addon has them set."""
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=PurePath("/addon/path/on/host"),
),
):
args = await coresys.run_in_executor(
build.get_docker_args, AwesomeVersion("1.0.0"), "test-image:1.0.0", None
)
assert _is_label_in_command(args["command"], "io.hass.name", "Terminal & SSH")
assert _is_label_in_command(
args["command"],
"io.hass.description",
"Allow logging in remotely to Home Assistant using SSH",
)
async def test_labels_omit_name_and_description_when_empty(
coresys: CoreSys, install_addon_ssh: Addon
):
"""Test name and description labels are omitted when addon has empty values."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
with (
patch.object(
type(install_addon_ssh), "name", new=PropertyMock(return_value="")
),
patch.object(
type(install_addon_ssh),
"description",
new=PropertyMock(return_value=""),
),
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=PurePath("/addon/path/on/host"),
),
):
args = await coresys.run_in_executor(
build.get_docker_args, AwesomeVersion("1.0.0"), "test-image:1.0.0", None
)
assert not _is_label_in_command(args["command"], "io.hass.name")
assert not _is_label_in_command(args["command"], "io.hass.description")
# Core labels should still be present
assert _is_label_in_command(args["command"], "io.hass.version", "1.0.0")
assert _is_label_in_command(args["command"], "io.hass.arch", "amd64")
assert _is_label_in_command(args["command"], "io.hass.type", "app")