# Docker Swarm stack for the hop-1 interim cutover — see
# thermograph-docs/runbooks/hop1-forgejo-registry-cutover.md (which this file implements) and
# thermograph-docs/architecture/repo-topology-and-infrastructure.md §6/§9.
#
# Distinct from docker-compose.yml (today's plain-compose deploy, unaffected by
# this file): Swarm pulls a pre-built image from the registry (IMAGE_TAG) rather
# than building in place, and needs Swarm-specific keys (deploy:, networks:,
# secrets:, no host-published ports on the overlay-fronted services).
#
#   docker stack deploy -c docker-stack.yml thermograph
#
# `docker stack deploy` only interpolates from the invoking shell's environment,
# not a .env file — export the required vars first (or `set -a; . ./stack.env;
# set +a; docker stack deploy ...`). Required shell vars: IMAGE_TAG (the tag CI
# pushed to the registry), TIMESCALEDB_TAG (an EXACT pinned minor — see the db
# service below, hazard #7). POSTGRES_PASSWORD is NOT a shell var here: unlike
# docker-compose.yml (which interpolates it into THERMOGRAPH_DATABASE_URL at
# compose time), this file reads it as the `postgres_password` Swarm secret —
# POSTGRES_PASSWORD_FILE for db (native support in the postgres/timescaledb
# image), and the chunk-3 entrypoint shim builds THERMOGRAPH_DATABASE_URL for
# app/worker from the same secret file at container start. All `postgres_password`
# / `thermograph_*` secrets below must already exist in the Swarm (`docker secret
# create <name> -`) before the first deploy — Track B step 7 in
# thermograph-docs/runbooks/implementation-handoff.md.
#
# Migrations are NOT run inline by app/worker here (RUN_MIGRATIONS=0) — the
# runbook's Stage F brings the schema to head via a one-shot task BEFORE scaling
# app up, so multiple replicas never race Alembic and nothing runs DDL against a
# still-read-only standby mid-cutover (hazard #9):
#
#   docker run --rm --network thermograph_internal \
#     -e THERMOGRAPH_DATABASE_URL=... <image> migrate

services:
  db:
    # Pin the EXACT TimescaleDB minor — never latest-pg18 here. A floating tag can
    # give two hosts different extension minors, which blocks a physical replica
    # (a newer .so over an older catalog won't start) and risks compressed-chunk
    # corruption on timescaledb_post_restore() (hazard #7). Get the exact X.Y.Z
    # from the source database: SELECT extversion FROM pg_extension WHERE
    # extname='timescaledb' — use the SAME tag docker-compose.yml's TIMESCALEDB_TAG
    # is pinned to, on every host that could ever replicate with this one.
    image: timescale/timescaledb:${TIMESCALEDB_TAG:?set TIMESCALEDB_TAG to an exact pinned minor, e.g. 2.17.2-pg18 -- not latest-pg18}
    environment:
      POSTGRES_USER: thermograph
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
      POSTGRES_DB: thermograph
      DB_MEMORY: ${DB_MEMORY:-8g}
    volumes:
      - pgdata:/var/lib/postgresql
      - ./deploy/db/init:/docker-entrypoint-initdb.d
    networks:
      - internal
    secrets:
      - postgres_password
    deploy:
      # Pin to the labelled DB node so replication/IO never crosses the slow WG
      # uplink (hazard #14): `docker node update --label-add db=true <node>` once,
      # on whichever node holds pgdata (Track B).
      placement:
        constraints: ["node.labels.db == true"]
      resources:
        limits:
          cpus: "${DB_CPUS:-2}"
          memory: ${DB_MEMORY:-8g}
      restart_policy:
        condition: on-failure
    # No published port: reachable only as db:5432 on the `internal` overlay —
    # Postgres must never be public (design doc §6).

  # Stateless, freely-replicable web tier. ROLE=web means _should_run_notifier()
  # is False unconditionally (web/app.py) — it never starts the notifier or the
  # worker scheduler even if it would otherwise win leader election.
  app:
    image: ${IMAGE_TAG:?set IMAGE_TAG to the image CI pushed, e.g. forge.example/thermograph/app:sha-abc123}
    environment:
      THERMOGRAPH_BASE: /
      PORT: 8137
      WORKERS: ${WORKERS:-4}
      THERMOGRAPH_ROLE: web
      RUN_MIGRATIONS: "0"
    volumes:
      - appdata:/app/data
      - applogs:/app/logs
    networks:
      - internal
    secrets:
      - postgres_password
      - thermograph_auth_secret
      - thermograph_vapid_private_key
      - thermograph_vapid_public_key
    deploy:
      replicas: 1   # single replica this hop -- homepage.json now lives in
                    # Postgres (chunk 4), but the notifier/scheduler split (chunk
                    # 2/5) is what actually lets this go multi-replica in Phase 2
      resources:
        limits:
          cpus: "${APP_CPUS:-4}"
      restart_policy:
        condition: on-failure
      update_config:
        order: start-first   # new task must pass the image's HEALTHCHECK (now
                              # GET /healthz) before the old one is stopped
    # No `ports:` — 127.0.0.1:8137:8137 (docker-compose.yml) has no Swarm
    # equivalent: Swarm's routing mesh publishes on 0.0.0.0, which would expose
    # the plaintext app un-fronted (hazard #6). Only the host's Caddy reaches
    # `app`, over the `internal` overlay network — see the Caddyfile templates'
    # health-gated reverse_proxy.

  # Owns the notifier + worker scheduler (chunks 1/2/5). Exactly one replica —
  # the leader-election guard is belt-and-suspenders, not a substitute for it.
  worker:
    image: ${IMAGE_TAG:?set IMAGE_TAG to the image CI pushed, e.g. forge.example/thermograph/app:sha-abc123}
    environment:
      THERMOGRAPH_BASE: /
      WORKERS: "1"
      THERMOGRAPH_ROLE: worker
      # Cluster-wide advisory lock, not the flock: the flock only arbitrates
      # workers on ONE host, so under Swarm it guards nothing across replicas.
      THERMOGRAPH_SINGLETON_PG: "1"
      RUN_MIGRATIONS: "0"
    volumes:
      - appdata:/app/data
      - applogs:/app/logs
    networks:
      - internal
    secrets:
      - postgres_password
      - thermograph_auth_secret
      - thermograph_vapid_private_key
      - thermograph_vapid_public_key
      - thermograph_discord_webhook
    deploy:
      replicas: 1
      resources:
        limits:
          cpus: "${WORKER_CPUS:-1}"
      restart_policy:
        condition: on-failure
    # No `ports:` — the worker serves no public traffic; GET /healthz on its own
    # container port is for the image's own HEALTHCHECK (Swarm task health) only.

networks:
  internal:
    driver: overlay
    driver_opts:
      # VXLAN-over-WireGuard double encapsulation needs a lower MTU than the
      # ~1450 overlay default, or large payloads (a /cell bundle, the homepage
      # feed) silently stall while small packets (health checks) keep passing
      # (hazard #13). Validate with a real large-payload transfer, not just a
      # ping, once the mesh is up (Track B).
      com.docker.network.driver.mtu: "1370"

volumes:
  pgdata: {}
  appdata: {}
  applogs: {}

# Declared here, provisioned externally (docker secret create <name> - < file) —
# never by this stack, and never committed. See Stage 0 of the cutover runbook
# for which values are continuity-critical (AUTH_SECRET, VAPID, POSTGRES_PASSWORD
# must be the EXISTING live values, not freshly generated ones).
secrets:
  postgres_password:
    external: true
  thermograph_auth_secret:
    external: true
  thermograph_vapid_private_key:
    external: true
  thermograph_vapid_public_key:
    external: true
  thermograph_discord_webhook:
    external: true
