"""Generate and validate the dockerfile.""" from dataclasses import dataclass from pathlib import Path 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 ) _GO2RTC_SHA = ( "675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae" # 1.9.14 ) 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 FROM ${{BUILD_FROM}} LABEL \ io.hass.type="core" \ org.opencontainers.image.authors="The Home Assistant Authors" \ org.opencontainers.image.description="Open-source home automation platform running on Python 3" \ org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.title="Home Assistant" \ org.opencontainers.image.url="https://www.home-assistant.io/" # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME={timeout} \ UV_SYSTEM_PYTHON=true \ UV_NO_CACHE=true WORKDIR /usr/src # Home Assistant S6-Overlay COPY rootfs / # Add go2rtc binary COPY --from=ghcr.io/alexxit/go2rtc@sha256:{go2rtc} /usr/local/bin/go2rtc /bin/go2rtc ## Setup Home Assistant Core dependencies COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/ RUN \ # Verify go2rtc can be executed go2rtc --version \ # Install uv at the version pinned in the requirements file && pip3 install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' homeassistant/requirements.txt)" \ && uv pip install \ --no-build \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ RUN \ if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ uv pip install homeassistant/home_assistant_*.whl; \ fi \ && uv pip install \ --no-build \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY --parents LICENSE* README* homeassistant/ pyproject.toml homeassistant/ RUN \ uv pip install \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant WORKDIR /config """ @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 files = [ File( DOCKERFILE_TEMPLATE.format( dockerfile_syntax=_DOCKERFILE_SYNTAX_SHA, timeout=timeout, go2rtc=_GO2RTC_SHA, ), config.root / "Dockerfile", ), 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)