"""Generate and validate the dockerfile.""" from dataclasses import dataclass from pathlib import Path import re from homeassistant import core from homeassistant.util import executor, thread from .model import Config, Integration # Don't forget to update also Dockerfile.dev when updating this. _DOCKERFILE_SYNTAX_SHA = ( "2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769" # 1.23.0 ) _DOCKERFILE_SYNTAX_PATTERN = re.compile(r"# syntax=docker/dockerfile@sha256:[0-9a-f]+") _DOCKERFILE_S6_GRACETIME_PATTERN = re.compile(r"S6_SERVICES_GRACETIME=\d+") def _update_dockerfile(content: str, timeout: int) -> str: """Update the hassfest-managed parts of the Dockerfile.""" content = _DOCKERFILE_SYNTAX_PATTERN.sub( f"# syntax=docker/dockerfile@sha256:{_DOCKERFILE_SYNTAX_SHA}", content, count=1, ) return _DOCKERFILE_S6_GRACETIME_PATTERN.sub( f"S6_SERVICES_GRACETIME={timeout}", content, count=1 ) @dataclass(frozen=True) class _MachineConfig: """Machine-specific Dockerfile configuration.""" arch: str packages: tuple[str, ...] = () _MACHINES = { "generic-x86-64": _MachineConfig(arch="amd64", packages=("libva-intel-driver",)), "green": _MachineConfig(arch="aarch64"), "khadas-vim3": _MachineConfig(arch="aarch64"), "odroid-c2": _MachineConfig(arch="aarch64"), "odroid-c4": _MachineConfig(arch="aarch64"), "odroid-m1": _MachineConfig(arch="aarch64"), "odroid-n2": _MachineConfig(arch="aarch64"), "qemuarm-64": _MachineConfig(arch="aarch64"), "qemux86-64": _MachineConfig(arch="amd64"), "raspberrypi3-64": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)), "raspberrypi4-64": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)), "raspberrypi5-64": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)), "yellow": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)), } _MACHINE_DOCKERFILE_TEMPLATE = r"""# syntax=docker/dockerfile@sha256:{dockerfile_syntax} # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM=ghcr.io/home-assistant/{arch}-homeassistant:latest FROM ${{BUILD_FROM}} {extra_packages} LABEL io.hass.machine="{machine}" """ def _generate_machine_dockerfile( machine_name: str, machine_config: _MachineConfig ) -> str: """Generate a machine Dockerfile from configuration.""" if machine_config.packages: pkg_lines = " \\\n ".join(machine_config.packages) extra_packages = f"\nRUN apk --no-cache add \\\n {pkg_lines}\n" else: extra_packages = "" return _MACHINE_DOCKERFILE_TEMPLATE.format( dockerfile_syntax=_DOCKERFILE_SYNTAX_SHA, arch=machine_config.arch, extra_packages=extra_packages, machine=machine_name, ) _HASSFEST_TEMPLATE = r"""# syntax=docker/dockerfile@sha256:{dockerfile_syntax} # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker FROM python:{python_version}-alpine ENV \ UV_SYSTEM_PYTHON=true \ UV_EXTRA_INDEX_URL="https://wheels.home-assistant.io/musllinux-index/" SHELL ["/bin/sh", "-o", "pipefail", "-c"] ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"] WORKDIR "/github/workspace" COPY --parents requirements.txt homeassistant/ script /usr/src/homeassistant/ # Uv creates a lock file in /tmp RUN --mount=type=tmpfs,target=/tmp \ --mount=type=bind,source=requirements_test.txt,target=/tmp/requirements_test.txt,readonly \ --mount=type=bind,source=requirements_test_pre_commit.txt,target=/tmp/requirements_test_pre_commit.txt,readonly \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ # Install uv at the version pinned in the requirements file && pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' /usr/src/homeassistant/requirements.txt)" \ && uv pip install \ --no-build \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ "pipdeptree==$(awk -F'==' '/^pipdeptree==/{{print $2}}' /tmp/requirements_test.txt)" \ "tqdm==$(awk -F'==' '/^tqdm==/{{print $2}}' /tmp/requirements_test.txt)" \ "ruff==$(awk -F'==' '/^ruff==/{{print $2}}' /tmp/requirements_test_pre_commit.txt)" LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " LABEL "com.github.actions.name"="hassfest" LABEL "com.github.actions.description"="Run hassfest to validate standalone integration repositories" LABEL "com.github.actions.icon"="terminal" LABEL "com.github.actions.color"="gray-dark" """ def _get_python_version(root: Path) -> str: """Extract the Python version from .python-version.""" return (root / ".python-version").read_text(encoding="UTF-8").strip() @dataclass class File: """File.""" content: str path: Path def _generate_files(config: Config) -> list[File]: timeout = ( core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + core.STOP_STAGE_SHUTDOWN_TIMEOUT + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 ) * 1000 dockerfile_path = config.root / "Dockerfile" files = [ File( _update_dockerfile(dockerfile_path.read_text(encoding="UTF-8"), timeout), dockerfile_path, ), File( _HASSFEST_TEMPLATE.format( dockerfile_syntax=_DOCKERFILE_SYNTAX_SHA, python_version=_get_python_version(config.root), ), config.root / "script/hassfest/docker/Dockerfile", ), ] for machine_name, machine_config in sorted(_MACHINES.items()): files.append( File( _generate_machine_dockerfile(machine_name, machine_config), config.root / "machine" / machine_name, ) ) return files def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate dockerfile.""" docker_files = _generate_files(config) config.cache["docker"] = docker_files for file in docker_files: if file.content != file.path.read_text(): config.add_error( "docker", f"File {file.path} is not up to date. Run python3 -m script.hassfest", fixable=True, ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate dockerfile.""" for file in _generate_files(config): file.path.write_text(file.content)