12e616ceae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
666 lines
32 KiB
YAML
666 lines
32 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:-}
|
|
# Fiscal receipts (54-ФЗ) are OFF: the merchant runs on НПД, which is outside 54-ФЗ, so no
|
|
# receipt is registered through the provider and each operation is reported to «Мой налог».
|
|
# Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on
|
|
# — the switch a lost НПД regime would need — with no code change.
|
|
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-}
|
|
# 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:
|