# Full deploy descriptor for the Scrabble test contour: backend + gateway +
# Postgres + the Telegram validator + bot (the bot with its VPN sidecar) + the
# observability stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
# .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are
# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
# job (see deploy/.env.example for the unprefixed names).
#
# Config bind sources are prefixed with ${SCRABBLE_CONFIG_DIR:-.}: locally they bind
# straight from this directory, but CI seeds them to a stable host path and sets
# SCRABBLE_CONFIG_DIR to it, because the runner's checkout is ephemeral (act removes
# it after the job) and the bind mounts must outlive the job in the long-running
# containers (see .gitea/workflows/ci.yaml + deploy/README.md).
#
# Networking (mirrors ../galaxy-game):
#   - `internal` (scrabble-internal): all inter-service traffic, project-private
#     DNS so service names never collide on the shared `edge` network.
#   - `edge` (external): the host caddy reaches this contour at `scrabble:80`
#     (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in
#     the test contour; the host caddy terminates TLS and forwards. For prod
#     (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
#     does its own ACME — the contour is then self-contained.
#   - The validator answers internal gRPC at `validator:9091` (no VPN, HMAC only).
#     The bot egresses to api.telegram.org through the `vpn` sidecar (network_mode:
#     service:vpn) and dials the gateway bot-link (mTLS) at `gateway:9443`. The
#     backend admin relay reaches the gateway at `gateway:9092` (plaintext).
name: scrabble

# Bound every container's json-file logs. The backend emits a per-request latency
# line at info (~14 MiB / 30 min under the 500-player peak); without rotation the
# volume grows unbounded. 10 MiB x 3 files caps each
# container at 30 MiB. Applied to every service via the *default-logging alias.
x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  postgres:
    container_name: scrabble-postgres
    image: postgres:17-alpine
    restart: unless-stopped
    logging: *default-logging
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-scrabble}
      POSTGRES_USER: ${POSTGRES_USER:-scrabble}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scrabble} -d ${POSTGRES_DB:-scrabble}"]
      interval: 5s
      timeout: 3s
      retries: 30
    volumes:
      - postgres-data:/var/lib/postgresql/data
    # 512M leaves headroom over the default 128 MB shared_buffers + per-connection
    # memory (the load harness peaked at 28 backends / 69 MiB RSS).
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 512M
    networks: [internal]

  backend:
    container_name: scrabble-backend
    image: scrabble-backend:latest
    build:
      context: ..
      dockerfile: backend/Dockerfile
      args:
        # Seed dictionary for a FRESH volume; the per-contour value comes from the
        # deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
        DICT_VERSION: ${DICT_VERSION:-v1.2.1}
        # Build version stamped into the binary (git tag; see pkg/version).
        VERSION: ${APP_VERSION:-dev}
    restart: unless-stopped
    logging: *default-logging
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      # search_path=backend matches the migrations (00001 creates the schema).
      BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
      # The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
      # for bursts. Postgres (2 cores / 512 MiB) handles it.
      BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
      BACKEND_HTTP_ADDR: ":8080"
      BACKEND_GRPC_ADDR: ":9090"
      # Admin broadcasts go to the gateway's bot-link relay, which forwards them to
      # the remote bot and reports back whether they were delivered.
      BACKEND_CONNECTOR_ADDR: gateway:9092
      BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info}
      BACKEND_SERVICE_NAME: scrabble-backend
      BACKEND_OTEL_TRACES_EXPORTER: otlp
      BACKEND_OTEL_METRICS_EXPORTER: otlp
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
      OTEL_EXPORTER_OTLP_INSECURE: "true"
      # GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
      # cgroup quota (the runtime otherwise sees all of the host's cores).
      GOMAXPROCS: "2"
    # The dictionary lives on a named volume seeded from the image on first boot
    # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
    # inherits). The admin console writes new version subdirectories here, and the
    # volume preserves them — and the versions in-progress games pin — across
    # redeploys. Once seeded the volume is not re-seeded: DICT_VERSION is the seed for
    # a FRESH volume only. On a live volume a changed DICT_VERSION is ignored (the
    # recorded .seed_version marker wins — the seed-drift guard), so a running contour's
    # dictionary is changed through the admin console, not by bumping the seed
    # (docs/ARCHITECTURE.md §5).
    volumes:
      - dawg-data:/opt/dawg
    # No container healthcheck: the distroless image has no shell/wget. Readiness
    # is covered by the CI post-deploy probe (GET / through caddy).
    # Generous over the ~1-core / <=100 MiB measured peak; the prod overlay trims these
    # to the launch-host values. deploy.resources.limits is
    # honoured by `docker compose up` (Compose v2), not only by swarm.
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 512M
    networks: [internal]

  gateway:
    container_name: scrabble-gateway
    image: scrabble-gateway:latest
    build:
      context: ..
      dockerfile: gateway/Dockerfile
      target: gateway
      args:
        VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
        VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
        VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
        VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
        VITE_APP_VERSION: ${APP_VERSION:-dev}
        # Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
        VERSION: ${APP_VERSION:-dev}
    restart: unless-stopped
    logging: *default-logging
    depends_on: [backend]
    environment:
      GATEWAY_HTTP_ADDR: ":8081"
      GATEWAY_BACKEND_HTTP_URL: http://backend:8080
      GATEWAY_BACKEND_GRPC_ADDR: backend:9090
      # Telegram auth validates against the home validator (plaintext, internal).
      GATEWAY_VALIDATOR_ADDR: validator:9091
      # The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
      # reaches the gateway at :9092 (plaintext, internal). In the test contour both
      # listeners stay on the internal network (the bot shares the VPN netns); in prod
      # the bot is a separate host and :9443 is published with public certificates.
      GATEWAY_BOTLINK_ADDR: ":9443"
      GATEWAY_BOTLINK_RELAY_ADDR: ":9092"
      GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt
      GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
      GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
      # Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
      # honeypot/honeytoken. Off by default: it bans by client IP, which is only
      # real in prod — the test contour arrives as one shared NAT address, so a ban
      # there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
      # these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
      GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
      GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
      GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
      GATEWAY_SERVICE_NAME: scrabble-gateway
      GATEWAY_OTEL_TRACES_EXPORTER: otlp
      GATEWAY_OTEL_METRICS_EXPORTER: otlp
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
      OTEL_EXPORTER_OTLP_INSECURE: "true"
      # GOMAXPROCS matches the CPU limit below (see backend).
      GOMAXPROCS: "3"
      # GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front
      # caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly.
    # The bot-link mTLS material (CA + gateway server leaf). Generated by
    # deploy/gen-certs.sh for the test contour; supplied from PROD_ secrets in prod.
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
    # The gateway holds one h2c connection per player, so at 500 players it
    # bursts into a 2-core cap (~2.49% transport_error on game.state); 3 cores absorbs
    # the bursts. Per-connection overhead is the realistic prod cost — size for it.
    deploy:
      resources:
        limits:
          cpus: "3.0"
          memory: 512M
    networks: [internal]

  # --- Landing (static) -------------------------------------------------------
  # The public landing page in its own caddy container: the contour caddy
  # routes the catch-all (notably /) here, the gateway keeps only /app/,
  # /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build
  # stage — identical build args keep that stage a single cached build.
  landing:
    container_name: scrabble-landing
    image: scrabble-landing:latest
    build:
      context: ..
      dockerfile: gateway/Dockerfile
      target: landing
      args:
        VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
        VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
        VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
        VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
        VITE_APP_VERSION: ${APP_VERSION:-dev}
    restart: unless-stopped
    logging: *default-logging
    deploy:
      resources:
        limits:
          memory: 128M
    networks: [internal]

  # --- Telegram validator (home; HMAC only, no VPN, no Telegram egress) -------
  # The validator holds the bot token solely as the HMAC secret and never reaches
  # the Bot API, so it runs on the main network with no VPN. Game login validates
  # against it and stays up even when the remote bot or the bot-link is down.
  validator:
    container_name: scrabble-telegram-validator
    image: scrabble-telegram-validator:latest
    build:
      context: ..
      dockerfile: platform/telegram/Dockerfile
      target: validator
      args:
        VERSION: ${APP_VERSION:-dev}
    restart: unless-stopped
    logging: *default-logging
    environment:
      # The token is the HMAC secret only; the validator never calls the Bot API. An
      # empty value crash-loops only the validator; the rest of the contour comes up.
      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
      TELEGRAM_VALIDATOR_GRPC_ADDR: ":9091"
      TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
      TELEGRAM_SERVICE_NAME: scrabble-telegram-validator
      TELEGRAM_OTEL_TRACES_EXPORTER: otlp
      TELEGRAM_OTEL_METRICS_EXPORTER: otlp
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
      OTEL_EXPORTER_OTLP_INSECURE: "true"
      GOMAXPROCS: "1"
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 128M
    networks: [internal]

  # --- Telegram bot (egress via the VPN sidecar in test; dials the gateway) ---
  # vpn + bot are gated to the `telegram-local` profile: the test contour runs them
  # locally (CI passes --profile telegram-local), the prod main host omits them, and
  # the prod bot runs on its own host from deploy/docker-compose.bot.yml.
  vpn:
    container_name: scrabble-telegram-vpn
    image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
    profiles: ["telegram-local"]
    restart: unless-stopped
    logging: *default-logging
    privileged: true
    environment:
      # Required by the vpn sidecar, which is gated to the telegram-local profile.
      # Compose can't scope a `:?` guard to a profile (interpolation runs for
      # profiled-out services too) and the prod main host has no VPN, so this is a soft
      # default; the test contour always supplies TEST_AWG_CONF and the sidecar validates it.
      AWG_CONF: ${AWG_CONF:-}
    networks:
      internal:
        aliases: [telegram]

  bot:
    container_name: scrabble-telegram-bot
    image: scrabble-telegram-bot:latest
    profiles: ["telegram-local"]
    build:
      context: ..
      dockerfile: platform/telegram/Dockerfile
      target: bot
      args:
        VERSION: ${APP_VERSION:-dev}
    restart: unless-stopped
    logging: *default-logging
    depends_on: [vpn]
    network_mode: "service:vpn"
    environment:
      # The bot token lives on the bot host (ARCHITECTURE.md §12). The bot requires it
      # at boot; an empty value leaves the bot down while the rest of the contour comes up.
      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
      TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
      # The moderated discussion chat (a channel's linked group) the bot gates write
      # access in. Empty disables gating. The group must ALLOW sending by default — the bot
      # only restricts (mutes the ineligible) — and the bot must be an admin there with the
      # "Ban users" right; chat_member updates are delivered only to a chat admin.
      TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
      # The optional standalone promo bot (its own token) answering /start with a button
      # into the main bot's app. Empty disables it; when set it needs the main bot's
      # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
      TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
      TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
      TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
      TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
      TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
      TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
      TELEGRAM_OWNS_UPDATES: "true"
      # The bot dials the gateway bot-link over mTLS. In test it reaches the gateway by
      # its internal service name through the VPN netns (Docker resolver, off-tunnel,
      # like otelcol); in prod it is a separate host dialing the gateway's public port.
      TELEGRAM_GATEWAY_ADDR: gateway:9443
      TELEGRAM_BOTLINK_SERVER_NAME: gateway
      TELEGRAM_BOTLINK_TLS_CERT: /certs/bot.crt
      TELEGRAM_BOTLINK_TLS_KEY: /certs/bot.key
      TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
      TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
      TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
      # The bot shares the VPN sidecar's netns. Routing to internal IPs stays off the
      # tunnel (connected route), but the sidecar's DNS hijacks name resolution:
      # AWG_CONF must NOT carry a `DNS=` directive, else `otelcol` and `gateway` won't
      # resolve. Without DNS= the netns uses Docker's resolver, which resolves the
      # internal services and api.telegram.org (see deploy/README.md).
      TELEGRAM_OTEL_TRACES_EXPORTER: otlp
      TELEGRAM_OTEL_METRICS_EXPORTER: otlp
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
      OTEL_EXPORTER_OTLP_INSECURE: "true"
      # The bot is light (the stress run does not drive Telegram); one P suffices.
      GOMAXPROCS: "1"
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 256M

  # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway;
  #     the catch-all incl. the landing -> the static landing container) -------
  caddy:
    container_name: scrabble-caddy
    image: caddy:2-alpine
    restart: unless-stopped
    logging: *default-logging
    depends_on: [gateway, backend, grafana, landing]
    environment:
      # Test: ":80" (host caddy terminates TLS). Prod: a domain for own ACME.
      CADDY_SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-:80}
      GM_BASICAUTH_USER: ${GM_BASICAUTH_USER:-gm}
      GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    deploy:
      resources:
        limits:
          memory: 128M
    networks:
      internal: {}
      edge:
        aliases: [scrabble]

  # --- Observability ---------------------------------------------------------
  otelcol:
    container_name: scrabble-otelcol
    image: otel/opentelemetry-collector-contrib:0.119.0
    restart: unless-stopped
    logging: *default-logging
    command: ["--config=/etc/otelcol/config.yaml"]
    # The docker_stats receiver reads per-container metrics from the Docker API, so the
    # collector (image UID 10001) joins the host's docker group to read the socket —
    # DOCKER_GID defaults to the contour host's 989; set it for other hosts (prod). The
    # socket is mounted read-only. This replaces cAdvisor, whose per-container metrics
    # are empty on this host (separate-XFS /var/lib/docker).
    group_add: ["${DOCKER_GID:-989}"]
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/otelcol/config.yaml:/etc/otelcol/config.yaml:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    deploy:
      resources:
        limits:
          memory: 512M
    networks: [internal]

  prometheus:
    container_name: scrabble-prometheus
    image: prom/prometheus:v2.55.1
    restart: unless-stopped
    logging: *default-logging
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --storage.tsdb.retention.time=15d
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    deploy:
      resources:
        limits:
          memory: 512M
    networks: [internal]

  tempo:
    container_name: scrabble-tempo
    image: grafana/tempo:2.7.1
    restart: unless-stopped
    logging: *default-logging
    command: ["-config.file=/etc/tempo/tempo.yaml"]
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
      - tempo-data:/var/tempo
    # Tempo reached the 1 GiB cap under sustained load (446 MiB in earlier runs);
    # raised to 2 GiB for headroom against OOM.
    deploy:
      resources:
        limits:
          memory: 2G
    networks: [internal]

  grafana:
    container_name: scrabble-grafana
    image: grafana/grafana:11.4.0
    restart: unless-stopped
    logging: *default-logging
    depends_on: [prometheus, tempo]
    environment:
      # Served under /_gm/grafana behind caddy's Basic-Auth; anonymous Admin so a
      # single shared login (caddy) gates it with no per-user Grafana accounts.
      GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-/_gm/grafana/}
      GF_SERVER_SERVE_FROM_SUB_PATH: "true"
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
      GF_AUTH_DISABLE_LOGIN_FORM: "true"
      GF_AUTH_BASIC_ENABLED: "false"
      GF_USERS_ALLOW_SIGN_UP: "false"
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
      # Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits
      # caddy's Basic-Auth and re-prompts for the password on every dashboard; the
      # dashboards poll and do not need Live.
      GF_LIVE_MAX_CONNECTIONS: "0"
    volumes:
      - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
      # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
      # grafana-data volume mounts over — a nested bind there is shadowed and the
      # provider logs "no such file or directory").
      - ${SCRABBLE_CONFIG_DIR:-.}/grafana/dashboards:/etc/grafana/dashboards:ro
      - grafana-data:/var/lib/grafana
    deploy:
      resources:
        limits:
          memory: 512M
    networks: [internal]

  # postgres_exporter exports Postgres server metrics (connections, cache hit ratio,
  # transactions, database size). Prometheus scrapes it at :9187. The DSN reuses the
  # contour Postgres credentials; sslmode=disable on the internal network.
  postgres_exporter:
    container_name: scrabble-postgres-exporter
    image: prometheuscommunity/postgres-exporter:v0.16.0
    restart: unless-stopped
    logging: *default-logging
    depends_on: [postgres]
    environment:
      DATA_SOURCE_NAME: postgresql://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable
    deploy:
      resources:
        limits:
          memory: 128M
    networks: [internal]

  # node_exporter exports host CPU/memory/disk metrics. The prod main host runs a tight
  # 1.9 GiB budget, so host memory pressure — not just per-container docker_stats — is
  # what warns before an OOM. Prometheus scrapes it at :9100 (see prometheus.yml).
  node_exporter:
    container_name: scrabble-node-exporter
    image: quay.io/prometheus/node-exporter:v1.8.2
    restart: unless-stopped
    logging: *default-logging
    command:
      - --path.rootfs=/host
      - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host)($|/)
    pid: host
    volumes:
      - /:/host:ro,rslave
    deploy:
      resources:
        limits:
          memory: 64M
    networks: [internal]

networks:
  internal:
    name: scrabble-internal
  edge:
    external: true

volumes:
  postgres-data:
  dawg-data:
  caddy-data:
  prometheus-data:
  tempo-data:
  grafana-data:
