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:
@@ -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}"])
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user