395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Two holes on the refund path, both found by asking what happens when a refund does not come from our own `/_gm` button. The merchant cabinet is a second entry point. An operator can refund there, and such a refund never passes through our API — so the money went back while the chips stayed credited, silently. Handle `refund.succeeded`: the refund is re-read from the API (the notification body is no more evidence here than it is for a payment), bound back to its order through the payment id recorded when the payment was minted, and reversed through the same engine. It is idempotent on (provider, refund id), so the event for a refund the console already recorded reverses nothing twice. The reversal engine is full-refund-only by design — it revokes exactly what the pack funded and rejects any other amount — so a partial refund is recorded as nothing at all and logged loudly for an operator. There is no non-arbitrary way to decide how many chips a part-refund costs, and guessing would be worse than asking a human. Second hole: a refund can still be canceled while pending, and the ledger is append-only. Recording on any non-empty refund id therefore risked revoking a customer's chips for money that stayed with us, with no way to take the row back. The console now records only a `succeeded` refund and tells the operator to press again otherwise — the idempotency key returns the same refund rather than paying twice. Tests: unit (GetRefund, the refund notification envelope, a non-final status surfaced to the caller); integration (a cabinet refund is reversed once and a redelivery is a no-op, the event after a console refund changes nothing, a partial refund records nothing, an unconfirmed refund reverses nothing, a pending refund records nothing until it settles and then does). The suite shares one database and the ledger dedupes refunds globally, so the fake provider now mints a refund id per payment — a constant id made one test's refund look like another's duplicate. Decisions D50 (amended) and D52; the notification subscription list in the deploy docs gains refund.succeeded.
664 lines
31 KiB
YAML
664 lines
31 KiB
YAML
# 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
|
|
# Built from postgres:17-alpine + pgBackRest (deploy/postgres/Dockerfile) so the prod
|
|
# main host can archive WAL for point-in-time recovery. Archiving is switched on only by
|
|
# the prod overlay (docker-compose.prod.yml); on this contour the extra binary is present
|
|
# but idle and this is a plain postgres. See deploy/README.md (point-in-time recovery).
|
|
image: scrabble-postgres:latest
|
|
build:
|
|
context: postgres
|
|
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]
|
|
|
|
# The finished-game image-render sidecar: internal-only, called by the backend for the
|
|
# PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client
|
|
# unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so —
|
|
# uniquely among the built services — it has a real container healthcheck the backend's
|
|
# depends_on gates on.
|
|
renderer:
|
|
container_name: scrabble-renderer
|
|
image: scrabble-renderer:latest
|
|
build:
|
|
context: ..
|
|
dockerfile: renderer/Dockerfile
|
|
args:
|
|
VERSION: ${APP_VERSION:-dev}
|
|
restart: unless-stopped
|
|
logging: *default-logging
|
|
environment:
|
|
RENDERER_PORT: "8090"
|
|
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
|
|
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
|
|
RENDERER_BACKEND_URL: http://backend:8080
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
# A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB.
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: "1.0"
|
|
memory: 192M
|
|
networks: [internal]
|
|
|
|
backend:
|
|
container_name: scrabble-backend
|
|
image: scrabble-backend:latest
|
|
build:
|
|
context: ..
|
|
dockerfile: backend/Dockerfile
|
|
args:
|
|
# Seed dictionary for a FRESH volume; required (no default) so the release tag is
|
|
# set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION)
|
|
# or .env for local builds. See the volume note below + deploy/README.md "Bumping the
|
|
# dictionary version".
|
|
DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env}
|
|
# 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
|
|
renderer:
|
|
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 finished-game export: the render sidecar address + the HMAC key signing the
|
|
# public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY).
|
|
BACKEND_RENDERER_URL: http://renderer:8090
|
|
BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret}
|
|
# 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"
|
|
# Transactional email (confirm-codes) via the shared Selectel relay. An empty
|
|
# host makes the backend log codes instead of sending them (dev default). TLS is
|
|
# implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
|
|
# (ssl|starttls) — needed for Selectel's non-standard ports (1127 = SSL, 1126 =
|
|
# STARTTLS). The From uses the prod domain (Selectel only accepts the verified
|
|
# sender domain), so it is the same on every contour. PUBLIC_BASE_URL is the
|
|
# canonical origin for links in the email (never a request Host header) —
|
|
# required whenever the relay host is set.
|
|
BACKEND_SMTP_HOST: ${SMTP_RELAY_HOST:-}
|
|
BACKEND_SMTP_PORT: ${SMTP_RELAY_PORT:-465}
|
|
BACKEND_SMTP_TLS: ${SMTP_RELAY_TLS:-}
|
|
BACKEND_SMTP_USERNAME: ${SMTP_RELAY_USER:-}
|
|
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
|
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
|
|
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
|
# Operator alert emails (new feedback / word complaints): a distinct sender and the
|
|
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
|
|
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
|
|
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
|
# Direct-rail (YooKassa) payment intake, one merchant shop per channel (D42): the shop id +
|
|
# secret key and a flag marking a test shop (deploy env: TEST_/PROD_BACKEND_YOOKASSA_*).
|
|
# Each shop credits the one direct wallet; a shop missing either credential drops that
|
|
# channel (order routing falls back to the web shop), and no shop at all leaves the direct
|
|
# order and notification endpoints unregistered. Notification URL (set per shop in the
|
|
# YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify — events payment.succeeded,
|
|
# payment.canceled and refund.succeeded.
|
|
BACKEND_YOOKASSA_WEB_SHOP_ID: ${YOOKASSA_WEB_SHOP_ID:-}
|
|
BACKEND_YOOKASSA_WEB_SECRET_KEY: ${YOOKASSA_WEB_SECRET_KEY:-}
|
|
BACKEND_YOOKASSA_WEB_TEST: ${YOOKASSA_WEB_TEST:-}
|
|
BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-}
|
|
BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-}
|
|
BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-}
|
|
# VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). 1 = «Без НДС» (УСН/ПСН).
|
|
# A deploy variable, not a secret, so a rate change is a variable edit + redeploy.
|
|
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-1}
|
|
# 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_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
|
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
|
|
# elsewhere so production shows real ads).
|
|
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
|
|
# 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
|
|
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
|
|
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
|
|
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
|
|
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
|
|
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
|
|
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
|
|
# Telegram auth validates against the home validator (plaintext, internal).
|
|
GATEWAY_VALIDATOR_ADDR: validator:9091
|
|
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
|
# the VK auth path (auth.vk is then unregistered).
|
|
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
|
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
|
|
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
|
|
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
|
|
# and redirect URL are the same values the SPA builds its authorize URL from (one
|
|
# source each). All three empty disables the link.vk.* ops.
|
|
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
|
|
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
|
|
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
|
# 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 still logs). The prod deploy forces it on
|
|
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
|
|
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
|
|
# secret; empty (unset secret) leaves the trap off.
|
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
|
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
|
|
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
|
|
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
|
|
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
|
|
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
|
|
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
|
|
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_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
|
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 private forum supergroup the bot relays direct user messages into (one topic
|
|
# per user) and reads operator replies from. Empty disables the support relay; when
|
|
# set the bot must be an admin there with the manage-topics and delete-messages rights.
|
|
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
|
TELEGRAM_SUPPORT_STATE_DIR: /data
|
|
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
|
|
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
|
|
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
|
|
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
|
|
# 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
|
|
# Support relay state (topic mapping, block list); survives redeploys.
|
|
- bot-state:/data
|
|
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
|
|
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
|
|
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
|
|
# from here while the flag exists (read-only mount — the deploy writes the flag on the
|
|
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
|
|
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint: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"
|
|
# SMTP for alert emails, reusing the shared relay host + credentials + the SERVICE
|
|
# From/recipient. Grafana's client speaks STARTTLS (not the backend's implicit-TLS
|
|
# port), so it dials the relay host on its STARTTLS port (GRAFANA_SMTP_PORT).
|
|
# Disabled unless GF_SMTP_ENABLED.
|
|
GF_SMTP_ENABLED: ${GF_SMTP_ENABLED:-false}
|
|
GF_SMTP_HOST: ${SMTP_RELAY_HOST:-}:${GRAFANA_SMTP_PORT:-}
|
|
GF_SMTP_USER: ${SMTP_RELAY_USER:-}
|
|
GF_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
|
# A BARE address (Grafana rejects the "Name" <addr> form); the deploy derives it from
|
|
# SMTP_RELAY_SERVICE_FROM, splitting off the display name into GRAFANA_SMTP_FROM_NAME.
|
|
GF_SMTP_FROM_ADDRESS: ${GRAFANA_SMTP_FROM_ADDRESS:-}
|
|
GF_SMTP_FROM_NAME: ${GRAFANA_SMTP_FROM_NAME:-Erudit Alerts}
|
|
GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS
|
|
# The alert recipient(s), read by the provisioned contact point via $__env{SERVICE_EMAIL}.
|
|
SERVICE_EMAIL: ${SERVICE_EMAIL:-}
|
|
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]
|
|
|
|
# blackbox_exporter lets Prometheus alert on TLS certificate expiry (a Caddy ACME
|
|
# renewal failure) via probe_ssl_earliest_cert_expiry. It probes the edge caddy that
|
|
# terminates TLS (prod: the published scrabble-caddy; the test contour has no compose
|
|
# caddy, so the probe simply finds no target and the cert metric is absent — the rule is
|
|
# absent-safe). See prometheus.yml and grafana alerting rules.
|
|
blackbox_exporter:
|
|
container_name: scrabble-blackbox-exporter
|
|
image: prom/blackbox-exporter:v0.25.0
|
|
restart: unless-stopped
|
|
logging: *default-logging
|
|
volumes:
|
|
- ${SCRABBLE_CONFIG_DIR:-.}/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
memory: 64M
|
|
networks: [internal, edge]
|
|
|
|
networks:
|
|
internal:
|
|
name: scrabble-internal
|
|
edge:
|
|
external: true
|
|
|
|
volumes:
|
|
postgres-data:
|
|
dawg-data:
|
|
caddy-data:
|
|
prometheus-data:
|
|
tempo-data:
|
|
grafana-data:
|
|
bot-state:
|