Dockerfile
Rama main · réplica de lectura del repositorio original.
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────────────────────
# soc-ai — multi-stage build
#
# Stage 1 (builder): installs uv, creates a venv, and syncs production deps.
# Stage 2 (frontend): builds the React SPA bundle (frontend/dist).
# Stage 3 (runtime): copies the venv + source + SPA into a slim Python 3.12
# image and runs as a non-root user.
#
# Nothing secret (no .env, no certs, no data) is baked into the image.
# All mutable state is injected at runtime via bind-mounts and volumes:
# - /opt/soc-ai/.env (read-only bind from the host)
# - /etc/soc-ai/cert.pem (read-only bind)
# - /etc/soc-ai/key.pem (read-only bind)
# - /var/lib/soc-ai/data (named volume — SQLite DB lives here)
# - /var/lib/soc-ai/blocklists (named volume — optional, survives restarts)
# - /var/lib/soc-ai/maxmind (named volume — optional GeoIP .mmdb files)
# - /var/lib/soc-ai/cloud_prefixes (named volume — optional cloud prefix JSON)
# - /var/lib/soc-ai/evals (named volume — nightly quality-eval artifacts)
# The /var/lib/soc-ai/* directories are created HERE, owned by uid 1000, so a
# named volume mounted over one inherits that ownership; a volume mounted at a
# path the image never created would be root-owned and unwritable.
# ─────────────────────────────────────────────────────────────────────────────
# ── Stage 1: dependency build ─────────────────────────────────────────────────
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim@sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58 AS builder
# ^ pinned by digest (supply-chain hardening, mirrors the SHA-pinned GitHub
# Actions in release.yml); tag above is for humans only — bump both
# together when refreshing. Resolve a new digest with:
# docker buildx imagetools inspect ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /build
# Copy only the files uv needs to resolve and lock deps.
# uv sync --no-install-project means it installs deps but not the project
# package itself; we copy the full source in the runtime stage.
COPY pyproject.toml uv.lock ./
# Sync production deps (no dev extras) into /build/.venv.
# --frozen: honour the lock file exactly (no re-resolution).
# --no-install-project: don't attempt to install the not-yet-copied source.
# --no-dev: skip [dependency-groups.dev].
# --extra postgres: asyncpg, for a store in PostgreSQL (SOC_AI_DATABASE_URL and
# the compose "postgres" profile). SQLite stays the default and needs nothing.
# --extra ml: scikit-learn and numpy, for the tier 3 estate model
# (estate_model_enabled, off by default). About 220 MB of site-packages on
# 2026-10-04. The app imports them only when the estate model runs.
RUN uv sync --frozen --no-install-project --no-dev --extra postgres --extra ml
# ── Stage 2: frontend build (React SPA → /fe/dist) ────────────────────────────
# Built here and copied into the runtime image at /opt/soc-ai/frontend/dist —
# where main.py's FRONTEND_DIST resolves, so FastAPI serves the SPA at /app.
FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS frontend
# ^ digest-pinned (see the builder stage's FROM comment above); tag: 22-bookworm-slim.
WORKDIR /fe
# Install against the lockfile first (cached unless package*.json changes).
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Build the production bundle (tsc -b && vite build → /fe/dist).
COPY frontend/ ./
RUN npm run build
# ── Stage 3: runtime ─────────────────────────────────────────────────────────
FROM python:3.12-slim@sha256:a0f57064cbdcd6285fa45f51237ab0b35ea8ebaed06a1f5973e89e19dfb86704 AS runtime
# ^ digest-pinned (see the builder stage's FROM comment above); tag: 3.12-slim.
# ── OS-level deps ─────────────────────────────────────────────────────────────
# curl: HEALTHCHECK; ca-certificates: trust chain for outbound HTTPS;
# openssh-client: the PCAP tool (soc_ai/tools/get_pcap.py) shells out to `ssh` to
# run tcpdump on the Security Onion sensor — without it, t_get_pcap fails with
# "ssh: not found" (the host having ssh masked this on non-container installs).
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*
# ── Non-root user ─────────────────────────────────────────────────────────────
RUN groupadd --gid 1000 soc-ai \
&& useradd --uid 1000 --gid soc-ai --no-create-home --shell /sbin/nologin soc-ai
# ── Directory layout ──────────────────────────────────────────────────────────
# /opt/soc-ai — application source
# /var/lib/soc-ai/... — runtime-writable data (mounted as volumes)
# /etc/soc-ai — TLS cert + key (mounted read-only by the host)
RUN mkdir -p \
/opt/soc-ai \
/var/lib/soc-ai/data \
/var/lib/soc-ai/blocklists \
/var/lib/soc-ai/maxmind \
/var/lib/soc-ai/cloud_prefixes \
/var/lib/soc-ai/evals \
/etc/soc-ai \
&& chown -R soc-ai:soc-ai \
/opt/soc-ai \
/var/lib/soc-ai \
/etc/soc-ai
# ── Copy venv from builder ─────────────────────────────────────────────────────
COPY --from=builder --chown=soc-ai:soc-ai /build/.venv /opt/soc-ai/.venv
# ── Copy application source ────────────────────────────────────────────────────
# soc_ai/ package (contains migrations in soc_ai/store/migrations/ and the
# CLI-only soc_ai/store/alembic.ini — the app runs migrations programmatically
# from Path(__file__), so the ini is only for the `alembic` CLI:
# alembic -c soc_ai/store/alembic.ini upgrade head
# pyproject.toml: read by some tooling; harmless to include.
COPY --chown=soc-ai:soc-ai soc_ai/ /opt/soc-ai/soc_ai/
COPY --chown=soc-ai:soc-ai pyproject.toml /opt/soc-ai/pyproject.toml
# runbooks/: the shipped starter-pack markdown (runbooks/starter-pack/*.md).
# POST /runbooks/starter-pack resolves it as parent-of-package (soc_ai/store/
# runbook_pack.py:STARTER_PACK_DIR → /opt/soc-ai/runbooks) — same layout trick
# as the frontend bundle below. Without this COPY the endpoint 404s honestly.
COPY --chown=soc-ai:soc-ai runbooks/ /opt/soc-ai/runbooks/
# docs/: not documentation for the operator, prompt input. The agent's system
# prompts are assembled from docs/OQL_PRIMER.md and docs/OQL_HUNT_EXAMPLES.md,
# resolved as parent-of-package (soc_ai/agent/prompts.py:PROMPT_ASSETS →
# /opt/soc-ai/docs), the same layout trick as runbooks/ above. Without this COPY
# the container starts and answers, but every investigator, hunt and chat prompt
# carries a stub saying the query language is unavailable, which is what shipped
# until 2026-09-07. Copied WHOLESALE, like soc_ai/ and runbooks/: naming the two
# files individually is exactly the mistake that produced the defect, because the
# next asset added under docs/ would silently not ship. The heavy published-only
# subtrees (docs/img, docs/superpowers) are excluded in .dockerignore, and
# tests/test_image_layout.py fails if any declared asset stops resolving here.
COPY --chown=soc-ai:soc-ai docs/ /opt/soc-ai/docs/
# Demo packaging (inert in normal deployments — the default CMD below never
# touches it). mock_es.py + its demo_dataset import are the bundled loopback
# Elasticsearch/LLM stand-in that docker/demo-entrypoint.sh starts on
# 127.0.0.1:9200; ONLY these two files are un-ignored in .dockerignore — the
# rest of scripts/ never ships in the image. The entrypoint itself is selected
# explicitly by docker-compose.demo.yml / render.yaml.
COPY --chown=soc-ai:soc-ai scripts/demo/mock_es.py scripts/demo/demo_dataset.py \
/opt/soc-ai/scripts/demo/
COPY --chmod=0755 --chown=soc-ai:soc-ai docker/demo-entrypoint.sh \
/opt/soc-ai/docker/demo-entrypoint.sh
# ── Copy the built React SPA ───────────────────────────────────────────────────
# FastAPI serves this at /app (mounted only when the dir exists). Without it the
# app still boots and the JSON API works, but /app 404s.
COPY --from=frontend --chown=soc-ai:soc-ai /fe/dist /opt/soc-ai/frontend/dist
WORKDIR /opt/soc-ai
# ── Build stamp ───────────────────────────────────────────────────────────────
# The commit this image was built from, baked in so the running process can name
# it. It cannot work this out for itself: .dockerignore excludes .git, and every
# build of one release carries the same version string while the deployments run
# the image as :latest — so without this, "quality dropped after Tuesday" has
# nothing to attribute itself to. Empty when the builder did not pass one, which
# soc_ai/__init__.py reads back as None rather than as a guess.
ARG SOC_AI_COMMIT=""
# ── Environment ───────────────────────────────────────────────────────────────
# PATH: venv bin dir takes precedence over system Python.
# PYTHONPATH: makes soc_ai importable without an editable install — hatchling
# (the build backend) is not a runtime dep, so we skip pip install entirely.
# pydantic-settings reads .env relative to cwd (/opt/soc-ai) — WORKDIR above.
# TLS + data-dir defaults match the compose volume mount paths; all can be
# overridden in .env without rebuilding the image.
ENV SOC_AI_COMMIT="${SOC_AI_COMMIT}" \
PATH="/opt/soc-ai/.venv/bin:$PATH" \
PYTHONPATH="/opt/soc-ai" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SOC_AI_HOST="0.0.0.0" \
SOC_AI_PORT="8443" \
SOC_AI_TLS_CERT="/etc/soc-ai/cert.pem" \
SOC_AI_TLS_KEY="/etc/soc-ai/key.pem" \
SOC_AI_DATA_DIR="/var/lib/soc-ai/data" \
BLOCKLIST_DATA_DIR="/var/lib/soc-ai/blocklists" \
MAXMIND_DATA_DIR="/var/lib/soc-ai/maxmind" \
CLOUD_PREFIX_DATA_DIR="/var/lib/soc-ai/cloud_prefixes" \
HOME="/var/lib/soc-ai"
# Console entry point. Stage 1's `uv sync --no-install-project` installs deps but
# NOT the project, so pyproject's [project.scripts] `soc-ai` is never generated in
# the venv — yet the docs and operator muscle-memory expect `soc-ai <cmd>` (backup,
# restore, blocklists refresh, audit verify, doctor) to work in-container (e.g.
# `docker exec soc-ai soc-ai backup`). Ship a tiny wrapper on PATH that runs the
# CLI through the venv python; PYTHONPATH (set above) makes soc_ai importable.
RUN printf '#!/opt/soc-ai/.venv/bin/python\nimport sys\nfrom soc_ai.cli import main\nsys.exit(main())\n' \
> /usr/local/bin/soc-ai \
&& chmod 0755 /usr/local/bin/soc-ai
USER soc-ai
EXPOSE 8443
# ── Liveness check ────────────────────────────────────────────────────────────
# This is a LIVENESS probe, and the word `healthy` that `docker ps` prints from
# it means only that this process answered. /healthz probes no dependency: it
# stays green with an unreachable grid, a dead model gateway and no analyst
# model at all, and it has to, because a probe that failed on a dependency
# outage would have Docker restart a container whose dependencies are merely
# down. The health verdict is `GET /api/v1/health` (live per-dependency probes)
# or `docker compose exec soc-ai python -m soc_ai doctor`; the response body
# says so too, so it reads correctly in `docker inspect`'s health log.
#
# /healthz answers over HTTPS on the direct path and over HTTP on the proxy
# path. The check tries HTTPS first, with -k to skip verify, then HTTP.
# Interval is generous (30s) so a slow cold-start (DB migration + bootstrap)
# doesn't flip the container unhealthy before the server is ready.
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -fsk https://127.0.0.1:8443/healthz || curl -fs http://127.0.0.1:8443/healthz || exit 1
# ── Default command ───────────────────────────────────────────────────────────
# Invoked as `python -m uvicorn`, NOT the `uvicorn` console script: the venv is
# built at /build/.venv (Stage 1) and copied to /opt/soc-ai/.venv, so the
# script's baked-in shebang (#!/build/.venv/bin/python) is dead — but the venv
# python + the uvicorn module are both fine. Cert/key paths come from ENV above.
# soc-ai applies its own forwarded-header trust rule (PROXY_TRUSTED_IPS).
CMD ["sh", "-c", \
"exec python -m uvicorn soc_ai.main:app \
--host 0.0.0.0 \
--port 8443 \
--no-proxy-headers \
--ssl-certfile \"${SOC_AI_TLS_CERT}\" \
--ssl-keyfile \"${SOC_AI_TLS_KEY}\" \
--ssl-ciphers 'ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM'"]