Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
82 changes: 61 additions & 21 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,44 +1,84 @@
# 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/*

# 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"]
9 changes: 8 additions & 1 deletion sentinel/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from pathlib import Path

import pytest
from _pytest.monkeypatch import MonkeyPatch
from pydantic import SecretStr, ValidationError
Expand Down Expand Up @@ -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")
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/test_dockerfile.py
Original file line number Diff line number Diff line change
@@ -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
Loading