# Docker Swarm stack for BETA, co-resident with prod on vps2.
#
# Deployed by the same deploy/stack/deploy-stack.sh as prod, which picks this
# file (and beta's ports, env file, LB and DB role) out of deploy/env-topology.sh
# when THERMOGRAPH_ENV=beta. Beta moved here from its own box so that a beta
# green light is evidence about prod: same orchestrator, same host kernel, same
# Postgres build, same Caddy, same mail path, same mesh position.
#
# ---------------------------------------------------------------------------
# WHY THIS IS A SEPARATE FILE AND NOT AN OVERLAY ON thermograph-stack.yml
# ---------------------------------------------------------------------------
# `docker stack deploy` accepts multiple -c files and MERGES them. Merging can
# add and override, but it cannot REMOVE a service — and the single most
# important fact about beta is a removal: it has no `db`. It uses prod's. An
# overlay would therefore still create a second Postgres, which is the exact
# thing this design exists to avoid. The same goes for the two autoscalers,
# which beta deliberately does not run.
#
# The cost is a file that must be kept roughly in step with prod's by hand.
# Keep them in step for anything that affects whether the APP works (env vars,
# entrypoints, healthchecks, the migrate contract). Do NOT keep them in step on
# scale, replicas or resource limits — those differ on purpose (below).
#
# ---------------------------------------------------------------------------
# THE THREE THINGS THAT MAKE CO-RESIDENCY SAFE
# ---------------------------------------------------------------------------
# 1. SERVICE NAMES ARE PREFIXED (beta-web, not web). Swarm registers a service's
#    short name as a DNS alias on every network it joins. Beta's tasks share the
#    `data` network with prod's, so two services both called `web` would make
#    `web` ambiguous — prod's frontend could resolve a beta task, and vice
#    versa. The prefix removes the collision without touching prod's stack file.
#
# 2. THE DATABASE IS SHARED, THE DATA IS NOT. Beta connects to prod's `db`
#    service as the role `thermograph_beta`, to the database `thermograph_beta`.
#    That role owns only its own database (see deploy/db/provision-env-db.sh),
#    so a beta deploy running an unmerged branch — or a migration that goes
#    wrong — cannot read or write production data. One server is a capacity
#    decision, not a trust decision.
#
# 3. NOTHING ELSE IS SHARED BY ACCIDENT. Separate checkout (/opt/thermograph-beta),
#    separate rendered env file (/etc/thermograph-beta.env), separate stack env
#    (/etc/thermograph/beta-stack.env), separate volumes, separate loopback
#    ports (8237/8180 — prod owns 8137/8080), separate LB container, separate
#    deploy lock and image-tag file. Every one of those is derived in
#    env-topology.sh rather than repeated here by hand.
#
# ---------------------------------------------------------------------------
# WHAT BETA DELIBERATELY DOES NOT DO
# ---------------------------------------------------------------------------
# - No autoscaling: fixed 1 replica per service. Beta exists to answer "does
#   this code work", not "does it scale"; a second replica would only add a
#   variable prod's rehearsal doesn't need, on a host prod is also using.
# - No IndexNow ping and no city-archive warm (deploy-stack.sh gates both on
#   TG_POST_DEPLOY). Pinging IndexNow from beta asks Bing/DuckDuckGo/Yandex to
#   index beta.thermograph.org; the warm spends the shared upstream archive
#   quota to fill a cache only a rehearsal reads.
# - No real mail and no Discord gateway. Both are governed by beta's vault
#   (THERMOGRAPH_MAIL_BACKEND=console, THERMOGRAPH_DISCORD_BOT=0) rather than
#   pinned here, so the operator can opt in with `sops edit` if a release ever
#   genuinely needs to rehearse them. Discord in particular allows ONE gateway
#   connection per bot token — beta and prod must never both hold one.

services:
  beta-web:
    image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
    entrypoint: ["/host/env-entrypoint.sh"]
    environment:
      # Beta's OWN role and OWN database on the shared instance. `db` resolves
      # across the external `data` network to prod's db service.
      THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta
      THERMOGRAPH_BASE: /
      PORT: 8137
      THERMOGRAPH_SERVICE_ROLE: backend
      THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://beta-frontend:8080
      WORKERS: ${BETA_WEB_WORKERS:-2}
      THERMOGRAPH_DATA_DIR: /state
      # Never the notifier/scheduler — that is beta-worker's job, exactly as in
      # prod, so the two files stay honest about which process owns what.
      THERMOGRAPH_ROLE: web
      # Migrations run as the one-shot task in deploy-stack.sh.
      RUN_MIGRATIONS: "0"
      # Overlay tasks reach the HOST's Postfix via the docker_gwbridge gateway.
      # Same host, same Postfix as prod — but see the mail note in the header:
      # beta's vault selects the console backend, so nothing is actually sent.
      THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1}
      THERMOGRAPH_LAKE_URL: http://beta-lake:8141
    volumes:
      - appdata:/state
      - applogs:/app/logs
      - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
      - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
    networks:
      - internal
      - data
    deploy:
      replicas: 1
      # vps2 is the Swarm manager and every volume here is local to it. The
      # desktop is a worker on this mesh and must never be scheduled the app.
      placement:
        constraints: ["node.role == manager"]
      resources:
        limits:
          cpus: "${BETA_WEB_CPUS:-2}"
      restart_policy:
        condition: on-failure
      update_config:
        order: start-first
        failure_action: rollback

  beta-worker:
    image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
    entrypoint: ["/host/env-entrypoint.sh"]
    environment:
      THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta
      THERMOGRAPH_BASE: /
      PORT: 8137
      THERMOGRAPH_SERVICE_ROLE: backend
      THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://beta-frontend:8080
      WORKERS: "1"
      THERMOGRAPH_DATA_DIR: /state
      THERMOGRAPH_ROLE: worker
      # The advisory lock is taken in beta's OWN database, so it can never
      # contend with prod's worker despite the shared server.
      THERMOGRAPH_SINGLETON_PG: "1"
      RUN_MIGRATIONS: "0"
      THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1}
      THERMOGRAPH_LAKE_URL: http://beta-lake:8141
    volumes:
      - appdata:/state
      - applogs:/app/logs
      - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
      - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
    networks:
      - internal
      - data
    deploy:
      replicas: 1
      placement:
        constraints: ["node.role == manager"]
      resources:
        limits:
          cpus: "${BETA_WORKER_CPUS:-1}"
      restart_policy:
        condition: on-failure

  beta-lake:
    image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
    entrypoint: ["/host/env-entrypoint.sh"]
    environment:
      THERMOGRAPH_ROLE: lake
      PORT: 8141
      THERMOGRAPH_SERVICE_ROLE: backend
      WORKERS: "1"
      THERMOGRAPH_LAKE_CACHE: /state/lake-cache
    volumes:
      # Beta's own cache volume. Deliberately not prod's: they are read caches
      # of the same bucket, but sharing a volume across two stacks would couple
      # their lifecycles for no gain.
      - lakecache:/state
      - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
      - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
    # No `data` network: the lake reads object storage, never Postgres.
    networks:
      - internal
    deploy:
      replicas: 1
      placement:
        constraints: ["node.role == manager"]
      resources:
        limits:
          cpus: "${BETA_LAKE_CPUS:-1}"
      restart_policy:
        condition: on-failure
      update_config:
        order: start-first
        failure_action: rollback

  beta-daemon:
    image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
    # Same reasoning as prod's daemon: NOT env-entrypoint.sh, because that shim
    # execs the image's own entrypoint (Alembic + uvicorn) and migrations belong
    # to the one-shot task. Source the host-rendered env and exec the binary.
    #
    # Note the ordering consequence, which is load-bearing here: this sources
    # the env file AFTER the `environment:` block is applied, so a key present
    # in /etc/thermograph/beta-stack.env WINS over one set below. That is why
    # THERMOGRAPH_DISCORD_BOT=0 lives in beta's vault and not in this file — a
    # value set here would be silently overridden if the vault ever set one.
    entrypoint:
      - /bin/bash
      - -c
      - 'set -a; [ -f /host/thermograph.env ] && . /host/thermograph.env; set +a; exec /usr/local/bin/thermograph-daemon'
    environment:
      THERMOGRAPH_API_BASE_INTERNAL: http://beta-web:8137
    volumes:
      - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
    # The image HEALTHCHECK curls /healthz on ${PORT}; the daemon serves
    # nothing, so without this override Swarm restarts it forever.
    healthcheck:
      disable: true
    networks:
      - internal
    deploy:
      # EXACTLY 1, and in beta's case the Discord gateway is off entirely
      # (vault: THERMOGRAPH_DISCORD_BOT=0) because prod's daemon holds the only
      # permitted gateway connection for that bot token.
      replicas: 1
      placement:
        constraints: ["node.role == manager"]
      resources:
        limits:
          cpus: "0.5"
          memory: 128m
      restart_policy:
        condition: on-failure
      update_config:
        order: stop-first
        failure_action: rollback

  beta-frontend:
    image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required}
    entrypoint: ["/host/env-entrypoint.sh"]
    # REQUIRED: overriding `entrypoint:` with no `command:` drops the image's
    # CMD entirely, and env-entrypoint.sh's fallback (`exec uvicorn app:app`)
    # does not exist in this Go image — the task would exit 127 every deploy.
    command: ["/usr/local/bin/thermograph-frontend"]
    environment:
      THERMOGRAPH_BASE: /
      PORT: 8080
      THERMOGRAPH_SERVICE_ROLE: frontend
      THERMOGRAPH_API_BASE_INTERNAL: http://beta-web:8137
    volumes:
      - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
      - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
    networks:
      - internal
    deploy:
      replicas: 1
      placement:
        constraints: ["node.role == manager"]
      resources:
        limits:
          cpus: "${BETA_FRONTEND_CPUS:-1}"
      restart_policy:
        condition: on-failure
      update_config:
        order: start-first
        failure_action: rollback

networks:
  # Beta's own east-west network: beta-web <-> beta-frontend <-> beta-lake, and
  # the loopback LB bridge joins it (hence attachable). Keeping this separate
  # from prod's overlay means beta's ordinary traffic never touches it.
  internal:
    driver: overlay
    attachable: true

  # Prod's overlay, joined ONLY to reach the shared `db` service. Declared
  # external because prod's stack owns it: `docker stack deploy` of THIS file
  # must never create, modify or (on `docker stack rm thermograph-beta`) remove
  # the network prod's database is on.
  #
  # Consequence worth knowing before you tear anything down: `docker stack rm
  # thermograph` would take this network with it and beta would lose its
  # database link until prod is redeployed.
  data:
    external: true
    name: thermograph_internal

volumes:
  # Beta's own, created by this stack under the thermograph-beta_ prefix. Unlike
  # prod's (which are `external` because they were inherited from the compose
  # era and hold live data), these can be recreated: beta's appdata is a parquet
  # cache plus derived files, and its DATABASE — the part that matters — lives
  # on the shared instance, not here.
  appdata: {}
  applogs: {}
  lakecache: {}
