Skip to content
Open
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
99 changes: 57 additions & 42 deletions supervisor/addons/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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 Down Expand Up @@ -48,20 +54,29 @@ def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None:
"""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
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.

Ugh yeah all bit meh here.

Maybe we should consider moving away from FileConfiguration for AddonBuild, since that is actually what this PR is trying to do: Not rely on build.json/build.yaml anymore 🤔 .

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.

Not sure I follow but the meh try-except allowing the file not to be present was here before as well. Majority of apps will keep using the structured config for quite a while, so not using FileConfiguration now would require us to handle it in different manner anyway.

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.

Well, a build.json/build.yaml was so far essentially mandatory. The code handled a graceful fallback which lead to using a empty dict, which ultimately applied the default schema of that config file.

In my books the old code was already a bit funky, since FileConfiguration is mostly used for Supervisor internal configuration files. But whatever, reuse I guess 🤷

However, now we want to get rid of build.json/build.yaml. Still relying on FileConfiguration always, let the system always come up with a empty build config, and then not use it seems just the wrong way around.

So I'd say, lets bite the bullet, and move away from FileConfiguration for AddonBuild now.

return self.addon.path_location / "build.json"

async def read_data(self) -> None:
Expand All @@ -81,10 +96,12 @@ def arch(self) -> CpuArch:
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"
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.

base_image changes the fallback when build_from is empty and a build config file exists: it now returns ghcr.io/home-assistant/base:latest instead of the previous arch-specific .../{arch}-base:latest. Unless ghcr.io/home-assistant/base is guaranteed to exist as a multi-arch manifest, this will break builds for add-ons with a build file but no build_from. Consider keeping the arch-specific fallback for backward compatibility (or documenting/centralizing the new canonical default).

Suggested change
return "ghcr.io/home-assistant/base:latest"
return f"ghcr.io/home-assistant/{MAP_ARCH[self.arch]}-base:latest"

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 is intentional, the images are now guaranteed to be multi-arch.

return None

if isinstance(self._data[ATTR_BUILD_FROM], str):
return self._data[ATTR_BUILD_FROM]
Expand Down Expand Up @@ -144,43 +161,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 +186 to +190
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 +210,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 +219 to +224
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: 7 additions & 0 deletions supervisor/docker/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,13 @@ async def _build(self, version: AwesomeVersion, image: str | None = None) -> Non
# Check if the build environment is valid, raises if not
await build_env.is_valid()

if build_env.has_build_file:
_LOGGER.warning(
"App %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(
Expand Down
Loading