From 9b068926a9e230278caaa412b146cff71ef05133 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 12:41:44 -0400 Subject: [PATCH] Containerize the API with a persistent volume (#183) --- .dockerignore | 27 +++++++++++++++++++++++++ .gitignore | 9 +++++++++ Dockerfile | 47 ++++++++++++++++++++++++++++++++++++++++++++ api/README.md | 32 ++++++++++++++++++++++++++++++ api/requirements.txt | 15 ++++++++++++++ deploy/.env.example | 17 ++++++++++++++++ deploy/compose.yaml | 41 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 188 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 api/README.md create mode 100644 api/requirements.txt create mode 100644 deploy/.env.example create mode 100644 deploy/compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c9792a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Keep the image build context minimal: only api/ and the bundled +# custom_components/nestquest/core/ are copied (see the Dockerfile); +# everything else — VCS state, virtualenvs, tests, the frontend, and +# any local secrets or database files — never enters the build. +.git +.gitignore +.venv +**/__pycache__ +**/*.py[cod] +.pytest_cache +**/*.db +**/*.db-wal +**/*.db-shm +# Local configuration — never baked into an image. +.env +**/.env +deploy +design +docs +frontend +orchestration_tests +scripts +templates +tests +uv.lock +hacs.json +*.md diff --git a/.gitignore b/.gitignore index cc771bf..ee069cc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ __pycache__/ .pytest_cache/ *.metadata.json frontend/node_modules/ + +# Feature 17 (API deployment): local secret config and runtime data +# are never committed — compose reads deploy/.env; SQLite/WAL sidecar +# files are container/volume artifacts. +.env +**/.env +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bec18f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Container image for the NestQuest API service (Feature 17). +# +# Build context is the REPOSITORY ROOT (compose sets it), because the +# API loads the bundled domain layer from +# custom_components/nestquest/core/ by file location +# (api/nestquest_core.py): the image must carry both api/ and the +# bundled core at their relative positions. Everything else in the +# repository is excluded via .dockerignore. +# +# Multi-stage so no pip/build toolchain survives into the runtime: +# the builder installs the deployable's own manifest +# (api/requirements.txt) into a staging prefix and the runtime copies +# only the installed packages. The service runs as a non-root user; +# the SQLite database lives on the mounted volume at +# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing +# there — configuration is environment-supplied, never baked in). +FROM python:3.12-slim AS builder + +COPY api/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt + +FROM python:3.12-slim + +# The staged dependency install from the builder (no pip, no wheels). +COPY --from=builder /install /usr/local + +# /app is the repository-root equivalent: api/ resolves imports as +# ``api.*`` and nestquest_core.py finds the bundled core one level up. +WORKDIR /app +COPY api/ ./api/ +COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/ + +# Non-root runtime user; owns the database volume mount point. +RUN useradd --system --create-home --home-dir /home/nestquest nestquest \ + && mkdir -p /var/lib/nestquest \ + && chown nestquest:nestquest /var/lib/nestquest +USER nestquest + +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 + +# The database path and the service token / OIDC settings are supplied +# by the environment (compose env_file), never committed into the image. +ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db +VOLUME /var/lib/nestquest + +EXPOSE 8080 +CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..376da9d --- /dev/null +++ b/api/README.md @@ -0,0 +1,32 @@ +# NestQuest API + +Standalone FastAPI service owning the NestQuest database (Feature 16). +Served with ``uvicorn --factory api.app:create_app``; configuration is +environment-supplied (``api/config.py``) and every variable is required. + +## Runtime dependencies + +``api/requirements.txt`` is the API deployable's OWN dependency +manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept +out of the integration's ``pyproject.toml`` dev group, which exists only +so the monorepo test venv can run the API tests. + +## Container deployment (scaffold) + +The image is built from the repository ROOT context (the API loads the +bundled domain core from ``custom_components/nestquest/core/`` by file +location, so both trees must be in the build context): + + docker build -t nestquest-api:latest . + +Run it with compose — the SQLite file lives on the named volume +``nestquest-db`` so recreating the container keeps household data: + + cp deploy/.env.example deploy/.env # then fill in real values + docker compose -f deploy/compose.yaml up -d --build + +``deploy/.env`` is git-ignored and supplies the secrets (panel service +token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to +``/var/lib/nestquest/nestquest.db`` by compose. The port is bound to +localhost only — TLS/reverse proxy, Authentik, network separation, +backup and restore are later Feature 17 tasks. \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..fed0749 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,15 @@ +# Runtime dependency manifest for the NestQuest API deployable. +# +# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in +# the INTEGRATION's dev group only (so the test venv can exercise the +# API); they are deliberately NOT integration runtime dependencies. +# Per that manifest's own comment, the API deployable declares its own +# runtime dependencies HERE — this file is what the container image +# installs (see the Dockerfile at the repository root). +# +# Compatible ranges mirroring the dev group's floors; the API is a +# server, so these are the only packages its runtime needs. +fastapi>=0.115 +uvicorn>=0.30 +pyjwt[crypto]>=2.9 +httpx>=0.28 diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..ee27088 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,17 @@ +# Example environment for the NestQuest API container deployment. +# +# Copy to ``deploy/.env`` (git-ignored) and fill in REAL values there; +# this example carries placeholder names only and is never a source of +# secrets. Compose loads ``deploy/.env`` into the api service; the +# API fails fast on startup when any of these is unset or empty +# (api/config.py — there are no silent defaults). + +# The static panel-plane service token the HA integration presents as +# a Bearer credential on /api/v1/panel/* routes. Generate one, e.g.: +# python -c "import secrets; print(secrets.token_urlsafe(32))" +NESTQUEST_PANEL_TOKEN= + +# The Authentik OIDC settings the admin plane verifies JWTs against. +NESTQUEST_OIDC_ISSUER= +NESTQUEST_OIDC_AUDIENCE= +NESTQUEST_OIDC_JWKS_URL= \ No newline at end of file diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..ea2be77 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,41 @@ +# Compose deployment scaffold for the NestQuest API service (Feature +# 17). The SQLite database lives on the NAMED VOLUME ``nestquest-db`` +# mounted at /var/lib/nestquest — recreating the container keeps the +# household data (``docker compose down`` leaves named volumes alone). +# +# Configuration is supplied by the environment: the fixed database +# path here, and the secret values (panel service token, Authentik +# OIDC settings) from ``deploy/.env`` — a git-ignored file you create +# from ``deploy/.env.example`` (placeholder names only; never commit +# real values). TLS/reverse proxy, Authentik wiring and network +# separation are later Feature 17 tasks; the port is bound to +# localhost only until those land. +services: + api: + build: + context: .. + dockerfile: Dockerfile + image: nestquest-api:latest + init: true + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + # The SQLite file inside the named volume; fixed, not a secret. + NESTQUEST_DB_PATH: /var/lib/nestquest/nestquest.db + env_file: + # Secret values (NESTQUEST_PANEL_TOKEN, NESTQUEST_OIDC_*); absent + # until you create deploy/.env from the example. + - path: .env + required: false + volumes: + - nestquest-db:/var/lib/nestquest + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + +volumes: + nestquest-db: \ No newline at end of file