Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 108 additions & 73 deletions supervisor/addons/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
import json
import logging
from pathlib import Path, PurePath
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Self

from awesomeversion import AwesomeVersion
import voluptuous as vol

from ..const import (
ATTR_ARGS,
Expand All @@ -19,7 +20,13 @@
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,
)
Expand All @@ -32,7 +39,7 @@
ConfigurationFileError,
HassioArchNotFound,
)
from ..utils.common import FileConfiguration, find_one_filetype
from ..utils.common import find_one_filetype, read_json_or_yaml_file
from .validate import SCHEMA_BUILD_CONFIG

if TYPE_CHECKING:
Expand All @@ -41,75 +48,105 @@
_LOGGER: logging.Logger = logging.getLogger(__name__)


class AddonBuild(FileConfiguration, CoreSysAttributes):
class AddonBuild(CoreSysAttributes):
"""Handle build options for add-ons."""

def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None:
def __init__(self, coresys: CoreSys, addon: AnyAddon, data: dict[str, Any]) -> None:
"""Initialize Supervisor add-on builder."""
self.coresys: CoreSys = coresys
self.addon = addon
self._build_config: dict[str, Any] = data

@classmethod
async def create(cls, coresys: CoreSys, addon: AnyAddon) -> Self:
"""Create an AddonBuild by reading the build configuration from disk."""
data = await coresys.run_in_executor(cls._read_build_config, addon)

if data:
_LOGGER.warning(
"App %s uses build.yaml which is deprecated. "
"Move build parameters into the Dockerfile directly.",
addon.slug,
)

if data[ATTR_SQUASH]:
_LOGGER.warning(
"Ignoring squash build option for %s as Docker BuildKit"
" does not support it.",
addon.slug,
)

# Search for build file later in executor
super().__init__(None, SCHEMA_BUILD_CONFIG)
return cls(coresys, addon, data or {})

def _get_build_file(self) -> Path:
"""Get build file.
@staticmethod
def _read_build_config(addon: AnyAddon) -> dict[str, Any] | None:
"""Find and read the build configuration file.

Must be run in executor.
"""
try:
return find_one_filetype(
self.addon.path_location, "build", FILE_SUFFIX_CONFIGURATION
build_file = find_one_filetype(
addon.path_location, "build", FILE_SUFFIX_CONFIGURATION
)
except ConfigurationFileError:
return self.addon.path_location / "build.json"
# No build config file found, assuming modernized build
return None

async def read_data(self) -> None:
"""Load data from file."""
if not self._file:
self._file = await self.sys_run_in_executor(self._get_build_file)
try:
raw = read_json_or_yaml_file(build_file)
build_config = SCHEMA_BUILD_CONFIG(raw)
except ConfigurationFileError as ex:
_LOGGER.exception(
"Error reading %s build config (%s), using defaults",
addon.slug,
ex,
)
build_config = SCHEMA_BUILD_CONFIG({})
except vol.Invalid as ex:
_LOGGER.warning(
"Error parsing %s build config (%s), using defaults", addon.slug, ex
)
build_config = SCHEMA_BUILD_CONFIG({})

await super().read_data()
# Default base image is passed in BUILD_FROM only when build.yaml is used
# (this is legacy behavior - without build config, Dockerfile should specify it)
if not build_config[ATTR_BUILD_FROM]:
build_config[ATTR_BUILD_FROM] = "ghcr.io/home-assistant/base:latest"

async def save_data(self):
"""Ignore save function."""
raise RuntimeError()
return build_config

@cached_property
def arch(self) -> CpuArch:
"""Return arch of the add-on."""
return self.sys_arch.match([self.addon.arch])

@property
def base_image(self) -> str:
"""Return base image for this add-on."""
if not self._data[ATTR_BUILD_FROM]:
return f"ghcr.io/home-assistant/{self.arch!s}-base:latest"
def base_image(self) -> str | None:
"""Return base image for this add-on, or None to use Dockerfile default."""
# No build config (otherwise default is coerced when reading the config)
if not self._build_config.get(ATTR_BUILD_FROM):
return None

if isinstance(self._data[ATTR_BUILD_FROM], str):
return self._data[ATTR_BUILD_FROM]
# Single base image in build config
if isinstance(self._build_config[ATTR_BUILD_FROM], str):
return self._build_config[ATTR_BUILD_FROM]

# Evaluate correct base image
if self.arch not in self._data[ATTR_BUILD_FROM]:
# Dict - per-arch base images in build config
if self.arch not in self._build_config[ATTR_BUILD_FROM]:
raise HassioArchNotFound(
f"Add-on {self.addon.slug} is not supported on {self.arch}"
)
return self._data[ATTR_BUILD_FROM][self.arch]

@property
def squash(self) -> bool:
"""Return True or False if squash is active."""
return self._data[ATTR_SQUASH]
return self._build_config[ATTR_BUILD_FROM][self.arch]

@property
def additional_args(self) -> dict[str, str]:
"""Return additional Docker build arguments."""
return self._data[ATTR_ARGS]
return self._build_config.get(ATTR_ARGS, {})

@property
def additional_labels(self) -> dict[str, str]:
"""Return additional Docker labels."""
return self._data[ATTR_LABELS]
return self._build_config.get(ATTR_LABELS, {})

def get_dockerfile(self) -> Path:
"""Return Dockerfile path.
Expand Down Expand Up @@ -144,43 +181,33 @@ def build_is_valid() -> bool:
system_arch_list=[arch.value for arch in self.sys_arch.supported],
) from None

def get_docker_config_json(self) -> str | None:
"""Generate Docker config.json content with registry credentials for base image.
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

Returns a JSON string with registry credentials for the base image's registry,
or None if no matching registry is configured.
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()

Raises:
HassioArchNotFound: If the add-on is not supported on the current architecture.
def get_docker_config_json(self) -> str | None:
"""Generate Docker config.json content with all configured registry credentials.

Returns a JSON string with registry credentials, or None if no registries
are configured.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposes more credentials then strictly necessary to the builder, but works, so I am fine with it 🤷

I wonder if there is really no other way to pass credentials to builder. But since that is rather recent, i guess this was really the only way to implement it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this is really a problem. The builder is actually the official docker image from the Docker Hub and the config file with credentials is supplied to that. When you run an image build on a local machine, the BuildKit builder has access to all the credentials in Docker config too, but that doesn't mean it can exfiltrate them - or does it? It should only allow to do operations on the registry that require authentication.

The credentials are copied to a temporary file on the host but there you have limited accessibility as well, and you can access other files containing them, so I don't see this an issue either.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the only way you could (miss)use the other credentials if you use multi-staged builds with other FROMs... But you could argue that is a feature, that we support multi-stage builds from different private repositories 😅 🤷 .

As I said, I am fine with it. Just thoughts.

"""
# 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})
Comment on lines +206 to +210
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_docker_config_json() now includes credentials for all configured registries and mounts them into the build environment. Because Dockerfiles can execute arbitrary commands during build, this increases the blast radius (a malicious/compromised Dockerfile could exfiltrate unrelated registry creds). If possible, limit injected credentials to registries actually needed for the build (e.g., those referenced in FROM lines / BUILD_FROM, or a minimal allowlist), or make the broader behavior opt-in with clear warnings.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO there are potentially more risks if someone installs builds shady Dockerfiles and I don't see a simple solution for this (unless we want to parse the Dockerfile to extract the image). In the end the authentication to a single registry may not be needed at all and we still supply the credentials.


def get_docker_args(
self, version: AwesomeVersion, image_tag: str, docker_config_path: Path | None
Expand All @@ -203,27 +230,35 @@ def get_docker_args(
]

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"):
Comment on lines +239 to +244
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new label-building order changes precedence: additional_labels are merged first, but then io.hass.name / io.hass.description are added afterwards, overriding any values provided via build.yaml labels. Previously, build-file labels could override the defaults. To preserve backward compatibility, consider only setting name/description if those labels are not already present, or merge additional_labels last.

Suggested change
# 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"):
# Set name only if non-empty and not already provided via additional_labels,
# could have been set in Dockerfile
if LABEL_NAME not in labels and (name := self._fix_label("name")):
labels[LABEL_NAME] = name
# Set description only if non-empty and not already provided via additional_labels,
# could have been set in Dockerfile
if LABEL_DESCRIPTION not in labels and (description := self._fix_label("description")):

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an unlikely edge case, and in the end it's IMO better for the dedicated fields from the config to take precedence.

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:
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build-arg precedence has changed: additional_args are merged first, but then BUILD_FROM is added afterwards, overriding any BUILD_FROM value that might be intentionally provided via args in the build config. Previously, args could override the default BUILD_FROM. If that override behavior is relied upon, consider only setting BUILD_FROM when it isn't already present in additional_args, or restoring the previous merge order.

Suggested change
if self.base_image is not None:
if self.base_image is not None and "BUILD_FROM" not in build_args:

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also quite unlikely, supplying BUILD_FROM in args would be an anti-pattern.

build_args["BUILD_FROM"] = self.base_image

for key, value in build_args.items():
build_cmd.extend(["--build-arg", f"{key}={value}"])

Expand Down
6 changes: 5 additions & 1 deletion supervisor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,15 @@
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"

Expand Down
7 changes: 1 addition & 6 deletions supervisor/docker/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,16 +680,11 @@ async def install(

async def _build(self, version: AwesomeVersion, image: str | None = None) -> None:
"""Build a Docker container."""
build_env = await AddonBuild(self.coresys, self.addon).load_config()
build_env = await AddonBuild.create(self.coresys, self.addon)
# Check if the build environment is valid, raises if not
await build_env.is_valid()

_LOGGER.info("Starting build for %s:%s", self.image, version)
if build_env.squash:
_LOGGER.warning(
"Ignoring squash build option for %s as Docker BuildKit does not support it.",
self.addon.slug,
)

addon_image_tag = f"{image or self.addon.image}:{version!s}"

Expand Down
Loading