build: multi-stage Docker image with uv and BuildKit caching (#1261)

* build: multi-stage Docker image with uv and BuildKit caching

Builder/runtime split keeps the toolchain out of the runtime image
(standalone ~1.8GB -> ~1.2GB). Dependencies install with uv from
uv.lock in a cache-mounted, source-independent layer. Runtime keeps
the editable install because akkudoktoreos.core.version needs the
src/ layout. Also fixes the io.hass.version label and adds a
HEALTHCHECK.

* build: address Docker image review feedback

- Healthcheck honours EOS_SERVER__PORT instead of hardcoding 8503.
- BUILD_VERSION defaults to "dev" rather than the literal "VERSION";
  docker-compose passes the real version, and the redundant
  org.opencontainers.image.version label is dropped (CI sets it via
  docker/metadata-action).

* fix: use resolved server port for container healthcheck

---------

Co-authored-by: Normann <github@koldrack.com>
Co-authored-by: Normann Koldrack <normann.koldrack@desy.de>
This commit is contained in:
Robert Neumann
2026-09-07 08:13:09 +02:00
committed by GitHub
co-authored by Normann Normann Koldrack
parent e9bd55caac
commit 2d18547aaa
5 changed files with 128 additions and 43 deletions
@@ -0,0 +1,52 @@
"""Lightweight container healthcheck using the port selected by the running server."""
import os
import sys
import tempfile
from pathlib import Path
from urllib.request import ProxyHandler, build_opener
PORT_FILE_ENV = "EOS_HEALTHCHECK_PORT_FILE"
def publish_port(port: int) -> None:
"""Atomically publish the startup port when container healthchecks are enabled.
Called after configuration resolution and privilege dropping. This preserves
CLI, environment and config-file precedence without loading EOS in the probe.
"""
filename = os.environ.get(PORT_FILE_ENV)
if not filename:
return
path = Path(filename)
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False
) as output:
temporary_path = Path(output.name)
output.write(str(port))
temporary_path.replace(path)
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
def main() -> int:
"""Return success only when the selected EOS port serves a healthy response."""
try:
port = int(Path(os.environ[PORT_FILE_ENV]).read_text(encoding="utf-8"))
if not 1 <= port <= 65535:
raise ValueError(f"Invalid server port: {port}")
# The probe is always local and must not use HTTP_PROXY from the container.
opener = build_opener(ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/v1/health", timeout=3) as response:
return 0 if response.status == 200 else 1
except (KeyError, OSError, ValueError) as error:
print(f"EOS healthcheck failed: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -73,6 +73,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules
from akkudoktoreos.server.container_healthcheck import publish_port
from akkudoktoreos.server.rest.error import (
EOSProblem,
create_error_page,
@@ -2360,6 +2361,9 @@ def run_eos() -> None:
# Switch privileges to run_as_user
drop_root_privileges(run_as_user=config_eos.server.run_as_user)
# Publish the effective startup port for the lightweight container probe.
publish_port(config_eos.server.port)
# Init the other singletons (besides config_eos)
singletons_init()