diff --git a/.env.example b/.env.example index 8260220..2abc354 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,12 @@ # Runtime mode: dev | test | prod SENTINEL_ENV=dev +# Directory holding the per-env YAML defaults (config/{env}.yaml). Leave unset +# for local/dev/test — the in-tree config/ is found automatically. The Docker +# image sets this to /app/config because the non-editable install relocates the +# package into site-packages. +# SENTINEL_CONFIG_DIR=/app/config + # Persistence SENTINEL_POSTGRES_DSN=postgresql+asyncpg://sentinel:sentinel@localhost:5432/sentinel SENTINEL_REDIS_URL=redis://localhost:6379/0 @@ -41,6 +47,8 @@ SENTINEL_GENERIC_WEBHOOK_SECRET= # Memory & embeddings (Work Area H) SENTINEL_EMBEDDING_MODEL_NAME=BAAI/bge-large-en-v1.5 +# Host default below. The Docker image overrides this to /opt/fastembed, where +# the model is pre-downloaded during the build for an offline-fast first start. SENTINEL_EMBEDDING_MODEL_CACHE_DIR=/var/cache/fastembed SENTINEL_EMBEDDING_COMPUTE_TIMEOUT_SECONDS=5.0 SENTINEL_KAFKA_CONSUMER_GROUP_MEMORY=sentinel-memory diff --git a/Dockerfile b/Dockerfile index 5223a41..5029a1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,51 @@ # syntax=docker/dockerfile:1.7 -# NOTE: single-stage editable install is intentional for the skeleton phase. -# Work Area M replaces this with a non-editable multi-stage build. +# Multi-stage build. The builder compiles the project + dependencies into an +# isolated virtualenv and pre-downloads the embedding model; the runtime stage +# copies only those artifacts, so build tooling never ships in the final image. +# Runs non-root with a liveness HEALTHCHECK. -FROM python:3.12-slim AS base +# ---- Builder --------------------------------------------------------------- +FROM python:3.12-slim AS builder ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 -# curl is needed for the compose healthcheck; build-essential is not needed -# because cp312 wheels cover all compiled deps. + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH + +# Build into an isolated venv so the runtime stage copies a single self-contained +# tree (/opt/venv) instead of mutating the system interpreter. +RUN python -m venv "$VIRTUAL_ENV" \ + && pip install --upgrade pip wheel + +WORKDIR /build +# pyproject declares `readme = "README.md"`, so the wheel build reads it — it must +# be present in the build context for a non-editable install. +COPY pyproject.toml README.md ./ +COPY sentinel ./sentinel +# Non-editable install: the package is baked into the venv's site-packages, so +# the runtime stage needs neither the source tree nor build backends. +RUN pip install . + +# Pre-download the fastembed model so the first container start is offline-fast. +ENV SENTINEL_EMBEDDING_MODEL_CACHE_DIR=/opt/fastembed +RUN python -c "from fastembed import TextEmbedding; \ + TextEmbedding(model_name='BAAI/bge-large-en-v1.5', cache_dir='/opt/fastembed')" + +# ---- Runtime --------------------------------------------------------------- +FROM python:3.12-slim AS runtime +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH \ + SENTINEL_EMBEDDING_MODEL_CACHE_DIR=/opt/fastembed + +# The package is installed non-editable, so the per-env YAML loader's +# `__file__`-relative default would resolve into site-packages. Point it at the +# config tree COPY'd to /app/config below. +ENV SENTINEL_CONFIG_DIR=/app/config + +# curl is needed for the HEALTHCHECK below; nothing else from the toolchain. RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* @@ -16,29 +53,32 @@ RUN apt-get update \ # Run as a non-root user for security. RUN useradd --create-home --uid 1000 sentinel +# Copy the built venv and the pre-downloaded model cache from the builder. +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder --chown=sentinel:sentinel /opt/fastembed /opt/fastembed + WORKDIR /app -COPY pyproject.toml ./ -COPY sentinel ./sentinel -COPY config ./config -COPY migrations ./migrations +# Alembic config + migrations and the per-env config tree are not part of the +# wheel; carry them so `alembic upgrade` can run against the container and +# settings can resolve per-env defaults. COPY alembic.ini ./ - -RUN pip install --upgrade pip wheel \ - && pip install -e . - -# Pre-download fastembed model so first container start is offline-fast. -ENV SENTINEL_EMBEDDING_MODEL_CACHE_DIR=/var/cache/fastembed -RUN python -c "from fastembed import TextEmbedding; \ - TextEmbedding(model_name='BAAI/bge-large-en-v1.5', cache_dir='/var/cache/fastembed')" \ - && chown -R sentinel:sentinel /var/cache/fastembed +COPY migrations ./migrations +COPY config ./config # Pre-create the LLM audit log directory so the non-root user can write to it. -# (Default Settings.llm_audit_log_path is `logs/llm-audit.log`, resolved relative -# to WORKDIR /app.) +# (Default Settings.llm_audit_log_path is `logs/llm-audit.log`, resolved +# relative to WORKDIR /app.) RUN mkdir -p /app/logs && chown sentinel:sentinel /app/logs USER sentinel # Single entrypoint: the API process owns HTTP plus the in-process Kafka consumers. EXPOSE 8000 + +# Liveness probe. /healthz returns 200 once the process is up; it deliberately +# does NOT depend on Postgres/Redis/Kafka (that is /readyz's job), so it is the +# correct signal for image-level health. start-period covers model load + boot. +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD curl -fsS http://localhost:8000/healthz || exit 1 + CMD ["sentinel-api"] diff --git a/sentinel/config/settings.py b/sentinel/config/settings.py index dee4c73..f7c88b1 100644 --- a/sentinel/config/settings.py +++ b/sentinel/config/settings.py @@ -26,7 +26,14 @@ class YamlConfigSettingsSource(PydanticBaseSettingsSource): def __init__(self, settings_cls: type[BaseSettings], env_name: str) -> None: super().__init__(settings_cls) - path = Path(__file__).resolve().parents[2] / "config" / f"{env_name}.yaml" + # Resolve the config directory. SENTINEL_CONFIG_DIR lets a deployment point + # at an explicit location; it is required once the package is installed + # non-editable (e.g. the Docker image), where `__file__` lives under + # site-packages and the `parents[2]`-relative path would miss the COPY'd + # `config/` tree. Defaults to the in-tree `config/` for local/dev/test. + config_dir = os.environ.get("SENTINEL_CONFIG_DIR") + base = Path(config_dir) if config_dir else Path(__file__).resolve().parents[2] / "config" + path = base / f"{env_name}.yaml" if path.exists(): data = yaml.safe_load(path.read_text()) or {} if not isinstance(data, dict): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2378cd3..3d742c2 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import pytest from _pytest.monkeypatch import MonkeyPatch from pydantic import SecretStr, ValidationError @@ -49,6 +51,28 @@ def test_env_overrides_yaml_default(monkeypatch: MonkeyPatch) -> None: assert settings.log_level == "WARNING" +def test_config_dir_env_override(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + """SENTINEL_CONFIG_DIR relocates the per-env YAML lookup. + + Required for non-editable installs (the Docker image): the package lives in + site-packages, so the `__file__`-relative default resolves to + `site-packages/config/` instead of the COPY'd `/app/config`. An explicit + config dir lets the image point the loader at the real location. + """ + (tmp_path / "dev.yaml").write_text("kafka_topic_incidents: custom.topic.from.configdir\n") + monkeypatch.setenv("SENTINEL_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("SENTINEL_ENV", "dev") + monkeypatch.setenv("SENTINEL_POSTGRES_DSN", "postgresql+asyncpg://u:p@h:5432/d") + monkeypatch.setenv("SENTINEL_REDIS_URL", "redis://h:6379/0") + monkeypatch.setenv("SENTINEL_KAFKA_BROKERS", "kafka:9092") + monkeypatch.setenv("SENTINEL_ANTHROPIC_API_KEY", "sk-test") + # No env var should shadow the YAML value under test. + monkeypatch.delenv("SENTINEL_KAFKA_TOPIC_INCIDENTS", raising=False) + + settings = Settings() + assert settings.kafka_topic_incidents == "custom.topic.from.configdir" + + def test_settings_missing_required_field_raises(monkeypatch: MonkeyPatch) -> None: monkeypatch.delenv("SENTINEL_POSTGRES_DSN", raising=False) monkeypatch.setenv("SENTINEL_REDIS_URL", "redis://h:6379/0") diff --git a/tests/unit/test_dockerfile.py b/tests/unit/test_dockerfile.py new file mode 100644 index 0000000..8f2fd35 --- /dev/null +++ b/tests/unit/test_dockerfile.py @@ -0,0 +1,64 @@ +"""Contract test for the runtime image. + +CI runs the app from a host virtualenv and only starts `postgres/redis/kafka` +from compose — it never builds the `app` image — so nothing else in the suite +guards the Dockerfile. The README's project-status table claims the production +image has landed; this test keeps that claim honest by asserting the structural +contract (multi-stage build, non-root, a liveness HEALTHCHECK) rather than the +single-stage editable-install skeleton it replaced (gh#73). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +_DOCKERFILE = Path(__file__).resolve().parents[2] / "Dockerfile" + + +@pytest.fixture(scope="module") +def dockerfile_text() -> str: + return _DOCKERFILE.read_text(encoding="utf-8") + + +def test_is_multistage(dockerfile_text: str) -> None: + """At least two named build stages — a builder and a runtime.""" + stages = re.findall(r"^FROM\s+\S+\s+AS\s+(\S+)", dockerfile_text, re.MULTILINE | re.IGNORECASE) + assert len(stages) >= 2, f"expected a multi-stage build, found stages={stages}" + + +def test_runtime_copies_from_an_earlier_stage(dockerfile_text: str) -> None: + """The runtime stage assembles itself from the builder via `COPY --from=`. + + This is the load-bearing difference from the old single-stage image: build + artifacts (the installed venv, the pre-downloaded embedding model) are + produced in the builder and copied forward, so build tooling never ships in + the runtime layer. + """ + assert "COPY --from=" in dockerfile_text + + +def test_has_liveness_healthcheck(dockerfile_text: str) -> None: + """A HEALTHCHECK probing the liveness endpoint must be baked into the image.""" + match = re.search(r"^HEALTHCHECK\b.*", dockerfile_text, re.MULTILINE | re.IGNORECASE) + assert match is not None, "Dockerfile defines no HEALTHCHECK" + # /healthz is liveness (process up); /readyz depends on external deps and is + # the wrong signal for image-level health. Either is accepted, but one must + # be probed. + healthcheck_block = dockerfile_text[match.start() :] + assert "/healthz" in healthcheck_block or "/readyz" in healthcheck_block + + +def test_runs_as_non_root(dockerfile_text: str) -> None: + """The final stage drops to a non-root user.""" + user_directives = re.findall(r"^USER\s+(\S+)", dockerfile_text, re.MULTILINE | re.IGNORECASE) + assert user_directives, "Dockerfile never sets USER (would run as root)" + assert user_directives[-1] != "root", "final USER must not be root" + + +def test_no_editable_install(dockerfile_text: str) -> None: + """The runtime image is a real install, not the `pip install -e .` skeleton.""" + assert "pip install -e" not in dockerfile_text + assert "--editable" not in dockerfile_text