From dbb29e93fb2f033d22b37fef99fd46f7c9c4d09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Wed, 1 Apr 2026 15:20:16 +0200 Subject: [PATCH] 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 --- supervisor/addons/build.py | 101 +++++++------ supervisor/const.py | 6 +- supervisor/docker/addon.py | 7 + tests/addons/test_build.py | 287 +++++++++++++++++++++++++++++-------- 4 files changed, 297 insertions(+), 104 deletions(-) diff --git a/supervisor/addons/build.py b/supervisor/addons/build.py index 41ae7934b..81ea4ed50 100644 --- a/supervisor/addons/build.py +++ b/supervisor/addons/build.py @@ -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}"]) diff --git a/supervisor/const.py b/supervisor/const.py index de94e2c97..996f32458 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -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" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index bf4627c8f..7fb326da8 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -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( diff --git a/tests/addons/test_build.py b/tests/addons/test_build.py index d585bd463..7debb375b 100644 --- a/tests/addons/test_build.py +++ b/tests/addons/test_build.py @@ -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")