1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-07-04 12:25:02 +01:00
Files
supervisor/tests/apps/test_build.py
T
Mike Degatano f8880a72be Rename addon/addons to app/apps in filenames and imports (#6837)
* Rename addon/addons to app/apps in filenames and imports

Continues the addon→app terminology migration (#6786).
Renames all source files, test files, fixture files, and
directories that contained 'addon'/'addons' in their names,
and updates all imports accordingly.

Resolution check files in supervisor/resolution/checks/ that were
renamed override the slug property to preserve the existing API
contract (slugs are exposed via the resolution info API and used
to run checks by name).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Rename add-on.json fixture

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 20:55:46 +02:00

455 lines
16 KiB
Python

"""Test app build."""
import base64
import json
import logging
from pathlib import Path, PurePath
from unittest.mock import PropertyMock, patch
from awesomeversion import AwesomeVersion
import pytest
from supervisor.apps.app import App
from supervisor.apps.build import AppBuild
from supervisor.coresys import CoreSys
from supervisor.docker.const import DOCKER_HUB, MountType
from supervisor.exceptions import AppBuildDockerfileMissingError
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_app_ssh: App):
"""Test platform set in container build args."""
build = await AppBuild.create(coresys, install_app_ssh)
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("latest"), "test-image:latest", None
)
assert is_in_list(["--platform", "linux/amd64"], args["command"])
async def test_dockerfile_evaluation(coresys: CoreSys, install_app_ssh: App):
"""Test dockerfile path in container build args."""
build = await AppBuild.create(coresys, install_app_ssh)
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("latest"), "test-image:latest", None
)
assert is_in_list(["--file", "Dockerfile"], args["command"])
assert str(await coresys.run_in_executor(build.get_dockerfile)).endswith(
"fixtures/apps/local/ssh/Dockerfile"
)
assert build.arch == "amd64"
async def test_dockerfile_evaluation_arch(coresys: CoreSys, install_app_ssh: App):
"""Test dockerfile arch evaluation in container build args."""
build = await AppBuild.create(coresys, install_app_ssh)
with (
patch.object(
type(coresys.arch), "supported", new=PropertyMock(return_value=["aarch64"])
),
patch.object(
type(coresys.arch), "default", new=PropertyMock(return_value="aarch64")
),
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("latest"), "test-image:latest", None
)
assert is_in_list(["--file", "Dockerfile.aarch64"], args["command"])
assert str(await coresys.run_in_executor(build.get_dockerfile)).endswith(
"fixtures/apps/local/ssh/Dockerfile.aarch64"
)
assert build.arch == "aarch64"
async def test_build_valid(coresys: CoreSys, install_app_ssh: App):
"""Test platform set in docker args."""
build = await AppBuild.create(coresys, install_app_ssh)
with (
patch.object(
type(coresys.arch), "supported", new=PropertyMock(return_value=["aarch64"])
),
patch.object(
type(coresys.arch), "default", new=PropertyMock(return_value="aarch64")
),
):
assert (await build.is_valid()) is None
async def test_build_invalid(coresys: CoreSys, install_app_ssh: App):
"""Test build not supported because Dockerfile missing for specified architecture."""
build = await AppBuild.create(coresys, install_app_ssh)
with (
patch.object(
type(coresys.arch), "supported", new=PropertyMock(return_value=["amd64"])
),
patch.object(
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
),
pytest.raises(AppBuildDockerfileMissingError),
):
await build.is_valid()
async def test_docker_config_no_registries(coresys: CoreSys, install_app_ssh: App):
"""Test docker config generation when no registries configured."""
build = await AppBuild.create(coresys, install_app_ssh)
# No registries configured by default
assert build.get_docker_config_json() is None
async def test_docker_config_all_registries(coresys: CoreSys, install_app_ssh: App):
"""Test docker config includes all configured registries."""
build = await AppBuild.create(coresys, install_app_ssh)
# pylint: disable-next=protected-access
coresys.docker.config._data["registries"] = {
"ghcr.io": {"username": "testuser", "password": "testpass"},
"some.other.registry": {"username": "user", "password": "pass"},
}
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"]
expected_ghcr = base64.b64encode(b"testuser:testpass").decode()
assert config["auths"]["ghcr.io"]["auth"] == expected_ghcr
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_app_ssh: App):
"""Test docker config uses special URL key for Docker Hub."""
build = await AppBuild.create(coresys, install_app_ssh)
# pylint: disable-next=protected-access
coresys.docker.config._data["registries"] = {
DOCKER_HUB: {"username": "hubuser", "password": "hubpass"}
}
config_json = build.get_docker_config_json()
assert config_json is not None
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
async def test_docker_args_with_config_path(coresys: CoreSys, install_app_ssh: App):
"""Test docker args include config volume when path provided."""
build = await AppBuild.create(coresys, install_app_ssh)
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",
side_effect=lambda p: PurePath(f"/extern{p}"),
),
):
config_path = Path("/data/supervisor/tmp/config.json")
args = await coresys.run_in_executor(
build.get_docker_args,
AwesomeVersion("latest"),
"test-image:latest",
config_path,
)
# Check that config is mounted (3 mounts: docker socket, app path, config)
assert len(args["mounts"]) == 3
config_mount = next(
m for m in args["mounts"] if m.target == "/root/.docker/config.json"
)
assert config_mount.source == "/extern/data/supervisor/tmp/config.json"
assert config_mount.read_only is True
assert config_mount.type == MountType.BIND
async def test_docker_args_without_config_path(coresys: CoreSys, install_app_ssh: App):
"""Test docker args don't include config volume when no path provided."""
build = await AppBuild.create(coresys, install_app_ssh)
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("latest"), "test-image:latest", None
)
# Only docker socket and app path should be mounted
assert len(args["mounts"]) == 2
# Verify no docker config mount
for mount in args["mounts"]:
assert mount.target != "/root/.docker/config.json"
async def test_build_file_deprecation_warning(
coresys: CoreSys, install_app_ssh: App, caplog: pytest.LogCaptureFixture
):
"""Test deprecation warning is logged when build.yaml exists."""
with caplog.at_level(logging.WARNING):
await AppBuild.create(coresys, install_app_ssh)
assert "uses build.yaml which is deprecated" in caplog.text
async def test_no_build_file_no_deprecation_warning(
coresys: CoreSys,
install_app_ssh: App,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
):
"""Test no deprecation warning 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_app_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
),
caplog.at_level(logging.WARNING),
):
await AppBuild.create(coresys, install_app_ssh)
assert "uses build.yaml which is deprecated" not in caplog.text
async def test_no_build_yaml_base_image_none(
coresys: CoreSys, install_app_ssh: App, 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_app_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
):
build = await AppBuild.create(coresys, install_app_ssh)
assert build.base_image is None
async def test_no_build_yaml_no_build_from_arg(
coresys: CoreSys, install_app_ssh: App, 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_app_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 AppBuild.create(coresys, install_app_ssh)
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_app_ssh: App):
"""Test BUILD_FROM is in docker args when build.yaml exists."""
build = await AppBuild.create(coresys, install_app_ssh)
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_app_ssh: App, 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_app_ssh),
"path_location",
new=PropertyMock(return_value=tmp_path),
):
build = await AppBuild.create(coresys, install_app_ssh)
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_app_ssh: App
):
"""Test name and description labels are included when app has them set."""
build = await AppBuild.create(coresys, install_app_ssh)
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_app_ssh: App
):
"""Test name and description labels are omitted when app has empty values."""
build = await AppBuild.create(coresys, install_app_ssh)
with (
patch.object(type(install_app_ssh), "name", new=PropertyMock(return_value="")),
patch.object(
type(install_app_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")