1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-23 17:00:13 +01:00
Files
core/script/hassfest/docker.py
T
2026-05-19 10:10:17 +02:00

198 lines
6.6 KiB
Python

"""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 <hello@home-assistant.io>"
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)