# Production-shape stack — the Dashboard with **OIDC auth**, the **supported single-instance
# SQLite** state store (ADR-012: SQLite is the certified topology), behind a **same-origin TLS
# reverse proxy** (see nginx.conf), pointed at an **external** kneo-serv you already run.
#
#   cp .env.example .env      # fill in the real values (secret, OIDC, KNEO_URL/KEY)
#   docker compose -f docker-compose.prod.yml --env-file .env up -d
#
# ⚠ SINGLE-INSTANCE. This is the topology the release smokes + supports. An external
# **Postgres** store (multi-replica/HA) is **best-effort, not certified**: the published
# image ships base-only, so Postgres needs an image built with the `[postgres]` extra
# (`pip install 'kneo-dash[postgres]'`) — see docker-compose.prod-postgres.yml / ADR-012.
#
# ⚠ AFTER A RESTORE, recovery is ENFORCED, not optional (ADR-012 §7). The `recover-gate`
# service below runs `kneo-dash recover --status` on every `up`; the server `depends_on` it, so
# it won't start when recovery is required. The gate blocks (and arms recovery) when the store
# is already in recovery OR when its restore-detection sentinel finds this DB is an older copy
# than last served — so a **file-level restore** (the documented `cp state.db` procedure) is
# caught even with no marker. (Caveats — see backup_and_recovery.md: a whole-VOLUME-snapshot
# restore carries the sidecar so it isn't distinguished, and Postgres has no local sidecar; both
# keep the manual `recover` step.) Restore runbook: restore the DB → `up` is blocked by the gate
# → `docker compose … run --rm recover-gate recover --reconcile …` → `up` starts the server.
services:
  # ADR-012 §7 deployment gate (external re-review HIGH — mechanical, not just a runbook).
  # A one-shot that exits 0 only when it's safe to serve — NOT in recovery AND the sentinel
  # doesn't flag this DB as an older (restored) copy; `kneo-dash` depends on it completing
  # successfully. Shares the state volume + DB URL with the server so it reads the same store
  # (and its sidecar). Also the entrypoint for the operator's recover/reconcile steps:
  # `docker compose … run --rm recover-gate recover …`.
  recover-gate:
    image: ${KNEO_DASH_IMAGE:-ghcr.io/kneo-agent/kneo-dash:0.8.0}
    entrypoint: ["kneo-dash"]
    command: ["recover", "--status"]
    environment:
      KNEO_DASH_DB_URL: ${KNEO_DASH_DB_URL:-sqlite:////var/lib/kneo-dash/state.db}
    volumes:
      - dash-state:/var/lib/kneo-dash
    restart: "no"

  kneo-dash:
    image: ${KNEO_DASH_IMAGE:-ghcr.io/kneo-agent/kneo-dash:0.8.0}
    restart: unless-stopped
    depends_on:
      recover-gate:
        condition: service_completed_successfully # won't start while in recovery
    environment:
      # --- platform (the external kneo-serv you operate) ---
      KNEO_URL: ${KNEO_URL:?set KNEO_URL to your kneo-serv base URL}
      KNEO_API_KEY: ${KNEO_API_KEY:?set KNEO_API_KEY (per-env service account)}
      # --- auth: real OIDC (Authorization Code + PKCE) ---
      KNEO_DASH_AUTH_MODE: oidc
      KNEO_DASH_SESSION_SECRET: ${KNEO_DASH_SESSION_SECRET:?32+ char random secret}
      KNEO_DASH_OIDC_ISSUER: ${KNEO_DASH_OIDC_ISSUER}
      KNEO_DASH_OIDC_CLIENT_ID: ${KNEO_DASH_OIDC_CLIENT_ID}
      KNEO_DASH_OIDC_CLIENT_SECRET: ${KNEO_DASH_OIDC_CLIENT_SECRET}
      KNEO_DASH_OIDC_REDIRECT_URL: ${KNEO_DASH_OIDC_REDIRECT_URL} # https://<host>/api/callback
      # role mapping: IdP group/claim → viewer|operator|admin (see .env.example)
      KNEO_DASH_OIDC_ROLE_MAP: ${KNEO_DASH_OIDC_ROLE_MAP:-}
      # --- dashboard-local state: SQLite on a persistent volume. The image already defaults
      # KNEO_DASH_DB_URL to sqlite:////var/lib/kneo-dash/state.db; the volume makes it durable.
      KNEO_DASH_DB_URL: ${KNEO_DASH_DB_URL:-sqlite:////var/lib/kneo-dash/state.db}
      # --- full production config surface (0.8.0 B4) --------------------------------
      # `--env-file` only supplies INTERPOLATION values — a variable not listed in this
      # block never reaches the container. So every production-relevant KNEO_DASH_* is
      # forwarded here (defaults mirror the BFF's own; empty == unset). Deliberately NOT
      # forwarded (dev-only, guarded by scripts/check_config_parity.py): KNEO_DASH_DEV_MODE,
      # KNEO_DASH_STATIC_OPERATOR_ROLE, KNEO_DASH_SPA_DIR (baked into the image),
      # KNEO_DASH_CORS_ORIGINS (same-origin prod keeps it empty).
      KNEO_DASH_OIDC_SCOPES: ${KNEO_DASH_OIDC_SCOPES:-openid email profile}
      KNEO_DASH_OIDC_ROLE_CLAIM: ${KNEO_DASH_OIDC_ROLE_CLAIM:-roles}
      KNEO_DASH_OIDC_BOOTSTRAP_ADMIN: ${KNEO_DASH_OIDC_BOOTSTRAP_ADMIN:-}
      KNEO_DASH_POST_LOGIN_REDIRECT: ${KNEO_DASH_POST_LOGIN_REDIRECT:-/}
      KNEO_DASH_SESSION_COOKIE_NAME: ${KNEO_DASH_SESSION_COOKIE_NAME:-kneo_dash_session}
      KNEO_DASH_SESSION_TTL_SECONDS: ${KNEO_DASH_SESSION_TTL_SECONDS:-43200}
      KNEO_DASH_SESSION_IDLE_SECONDS: ${KNEO_DASH_SESSION_IDLE_SECONDS:-1800}
      KNEO_DASH_SESSION_PURGE_INTERVAL_SECONDS: ${KNEO_DASH_SESSION_PURGE_INTERVAL_SECONDS:-3600}
      KNEO_DASH_DEFAULT_PROFILE: ${KNEO_DASH_DEFAULT_PROFILE:-}
      KNEO_DASH_STUCK_RUNNING_SECONDS: ${KNEO_DASH_STUCK_RUNNING_SECONDS:-120}
      KNEO_DASH_STUCK_BLOCKED_SECONDS: ${KNEO_DASH_STUCK_BLOCKED_SECONDS:-300}
      KNEO_DASH_HITL_NEAR_DEADLINE_SECONDS: ${KNEO_DASH_HITL_NEAR_DEADLINE_SECONDS:-300}
      KNEO_DASH_SSE_SEND_TIMEOUT_SECONDS: ${KNEO_DASH_SSE_SEND_TIMEOUT_SECONDS:-30.0}
      KNEO_DASH_SSE_MAX_STREAMS_PER_OPERATOR: ${KNEO_DASH_SSE_MAX_STREAMS_PER_OPERATOR:-5}
      # /metrics stays disabled unless a ≥32-char token is supplied (B1 soak harness
      # scrapes through this — the gap that blocked monitoring pre-B4).
      KNEO_DASH_METRICS_TOKEN: ${KNEO_DASH_METRICS_TOKEN:-}
      KNEO_DASH_GRAFANA_URL: ${KNEO_DASH_GRAFANA_URL:-}
      # served same-origin behind the proxy, so no CORS needed
    volumes:
      - dash-state:/var/lib/kneo-dash # the SQLite state DB — survives restarts
    expose: ["8090"] # published only via the reverse proxy, not to the host

  proxy:
    image: nginx:1.27@sha256:6784fb0834aa7dbbe12e3d7471e69c290df3e6ba810dc38b34ae33d3c1c05f7d # digest-pinned (tp2-R3): the soaked fixture must be identifiable — a mutable tag can move under the certification
    restart: unless-stopped
    depends_on: [kneo-dash]
    ports: ["443:443", "80:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/kneo-dash.conf:ro
      - ./tls:/etc/nginx/tls:ro # drop your fullchain.pem + privkey.pem here

volumes:
  dash-state:
