Compare commits
30 Commits
72e9b600b0
..
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 829e29a726 | |||
| 44117e906c | |||
| c90331b189 | |||
| 399508f2f0 | |||
| 0dfecc2af7 | |||
| 446ea2ac45 | |||
| 01a9249002 | |||
| 7dcd62fdd7 | |||
| d67e582c03 | |||
| 75fe07865a | |||
| 597e200f37 | |||
| 3ef18d33b6 | |||
| c8601c0115 | |||
| a0021d1994 | |||
| 4b4dcab9b6 | |||
| 7f85362288 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+59
-29
@@ -26,12 +26,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [development]
|
branches: [development]
|
||||||
|
|
||||||
# The dictionary release the test suite validates against — the current
|
# The dictionary release. One Gitea variable is the single source of truth: the
|
||||||
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
# test suite validates against it here (inherited by the unit/integration jobs) and
|
||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# is one edit (the variable). See deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.1
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -329,20 +329,25 @@ jobs:
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||||
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
# One VK Mini App serves every contour -> unprefixed secret.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
|
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
|
||||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||||
# App above. Empty leaves the link.vk.* ops disabled.
|
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.TEST_GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||||
|
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay. Empty host leaves the
|
# Transactional email via the shared Selectel relay: one account for every
|
||||||
# backend on the log mailer (email disabled) but the contour still boots.
|
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
|
||||||
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
|
# on the log mailer (email disabled) but the contour still boots.
|
||||||
SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }}
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||||
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||||
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
||||||
@@ -351,15 +356,15 @@ jobs:
|
|||||||
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
|
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
|
||||||
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
|
||||||
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
|
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
|
||||||
GRAFANA_SMTP_PORT: ${{ vars.TEST_GRAFANA_SMTP_PORT }}
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
|
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
|
||||||
# Canonical public origin for links in the email (this contour's URL);
|
# Canonical public origin for links in the email (this contour's URL);
|
||||||
# required by the backend whenever SMTP_RELAY_HOST is set.
|
# required by the backend whenever SMTP_RELAY_HOST is set.
|
||||||
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
|
||||||
|
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -372,16 +377,16 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
|
||||||
# VK ID web login: the "Web" app id + its trusted redirect URL. One value each feeds
|
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
|
||||||
# both the SPA (the authorize URL) and the gateway (client_id / exchange redirect_uri).
|
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
|
||||||
VITE_VK_APP_ID: ${{ vars.TEST_VITE_VK_APP_ID }}
|
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
VITE_VK_ID_REDIRECT_URL: ${{ vars.TEST_VK_ID_REDIRECT_URL }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||||
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
||||||
run: |
|
run: |
|
||||||
# Seed the config files to a stable host path. The runner checks out into
|
# Seed the config files to a stable host path. The runner checks out into
|
||||||
@@ -394,6 +399,20 @@ jobs:
|
|||||||
mkdir -p "$conf"
|
mkdir -p "$conf"
|
||||||
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
||||||
export SCRABBLE_CONFIG_DIR="$conf"
|
export SCRABBLE_CONFIG_DIR="$conf"
|
||||||
|
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
|
||||||
|
# overlay is exercised on the test contour too (not only prod). Raised just before
|
||||||
|
# the recreate and lowered once caddy is back (below); the trap clears it if the
|
||||||
|
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
|
||||||
|
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
|
||||||
|
maint_flag="$conf/caddy/on"
|
||||||
|
trap 'rm -f "$maint_flag"' EXIT
|
||||||
|
# Derive the public URLs from the one canonical origin instead of storing each as
|
||||||
|
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
|
||||||
|
# Exported before build so the VK ID redirect is baked into the SPA.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
export TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
|
||||||
|
export VITE_VK_ID_REDIRECT_URL="$base/app/"
|
||||||
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
|
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
|
||||||
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
|
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
|
||||||
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
|
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
|
||||||
@@ -418,12 +437,23 @@ jobs:
|
|||||||
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
||||||
# main host omits both. Without the profile they would not start here.
|
# main host omits both. Without the profile they would not start here.
|
||||||
docker compose --ansi never --profile telegram-local build --progress plain
|
docker compose --ansi never --profile telegram-local build --progress plain
|
||||||
|
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
|
||||||
|
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
|
||||||
|
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
|
||||||
|
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
|
||||||
|
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
|
||||||
|
: > "$maint_flag"
|
||||||
|
docker compose --ansi never up -d --force-recreate --no-deps caddy
|
||||||
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
||||||
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||||
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
|
||||||
# pick up the fresh config.
|
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
|
||||||
|
# Lower the maintenance page: services are back. An open SPA's poll now gets through
|
||||||
|
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
|
||||||
|
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
|
||||||
|
rm -f "$maint_flag"
|
||||||
|
|
||||||
- name: Probe the landing, gateway and backend
|
- name: Probe the landing, gateway and backend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -40,15 +40,22 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
|
||||||
# VK ID web login: the "Web" app id + trusted redirect URL, baked into the SPA authorize URL.
|
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }}
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
|
||||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
# runtime var must be present at build even though it is not a build-arg — incl. the
|
||||||
|
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
|
||||||
|
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
|
||||||
|
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
|
||||||
|
# Mini App URL; both are computed in the build step, not stored variables.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -62,6 +69,11 @@ jobs:
|
|||||||
working-directory: deploy
|
working-directory: deploy
|
||||||
run: |
|
run: |
|
||||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
||||||
|
# Derive the public URLs from the one canonical origin: the VK ID redirect is a
|
||||||
|
# build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out)
|
||||||
|
# bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
||||||
# the bot separately, since it is profiled out of the prod compose.
|
# the bot separately, since it is profiled out of the prod compose.
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
||||||
@@ -86,20 +98,24 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
# VK ID web login: the SPA app id + redirect URL (the gateway reuses them as its
|
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
||||||
# client_id / exchange redirect_uri at runtime) and the "Web" app's protected key.
|
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
||||||
VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }}
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.PROD_GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||||
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes).
|
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||||
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
|
# every contour -> unprefixed host/port/tls/user/pass.
|
||||||
SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }}
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
|
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
|
||||||
# recipients; Grafana uses the relay's STARTTLS host:port).
|
# recipients; Grafana uses the relay's STARTTLS host:port).
|
||||||
@@ -107,20 +123,20 @@ jobs:
|
|||||||
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||||
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
||||||
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||||
GRAFANA_SMTP_PORT: ${{ vars.PROD_GRAFANA_SMTP_PORT }}
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
||||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
|
||||||
|
# deploy/write-prod-env.sh, not stored variables.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -148,56 +164,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
# Grafana needs a BARE from-address (it rejects the "Name" <addr> form the backend
|
# Shared with prod-rollback so the two paths render an identical runtime env.
|
||||||
# accepts, and validates it even when disabled); split the display-format here.
|
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
|
|
||||||
GRAFANA_SMTP_FROM_NAME=''
|
|
||||||
case "$svc_from" in
|
|
||||||
*"<"*">"*)
|
|
||||||
GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
|
|
||||||
GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
|
|
||||||
*) GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
|
|
||||||
esac
|
|
||||||
cat > stage/env.sh <<EOF
|
|
||||||
export REGISTRY='$REGISTRY'
|
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
|
||||||
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
|
||||||
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
|
||||||
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
|
||||||
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
|
||||||
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
|
||||||
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
|
||||||
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
|
||||||
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
|
||||||
export APP_VERSION='$TAG'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
|
||||||
# VK ID web login: needed at runtime — the gateway's GATEWAY_VK_ID_* env resolves
|
|
||||||
# its app id / redirect URL from these VITE_ values (one source, shared with the SPA).
|
|
||||||
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
|
||||||
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
|
||||||
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
|
||||||
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
|
||||||
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
|
||||||
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
|
||||||
export SMTP_RELAY_TLS='$SMTP_RELAY_TLS'
|
|
||||||
export SMTP_RELAY_USER='$SMTP_RELAY_USER'
|
|
||||||
export SMTP_RELAY_PASS='$SMTP_RELAY_PASS'
|
|
||||||
export SMTP_RELAY_FROM='$SMTP_RELAY_FROM'
|
|
||||||
export SMTP_RELAY_ADMIN_FROM='$SMTP_RELAY_ADMIN_FROM'
|
|
||||||
export ADMIN_EMAIL='$ADMIN_EMAIL'
|
|
||||||
export SMTP_RELAY_SERVICE_FROM='$SMTP_RELAY_SERVICE_FROM'
|
|
||||||
export GRAFANA_SMTP_FROM_ADDRESS='$GRAFANA_SMTP_FROM_ADDRESS'
|
|
||||||
export GRAFANA_SMTP_FROM_NAME='$GRAFANA_SMTP_FROM_NAME'
|
|
||||||
export SERVICE_EMAIL='$SERVICE_EMAIL'
|
|
||||||
export GRAFANA_SMTP_PORT='$GRAFANA_SMTP_PORT'
|
|
||||||
export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
|
||||||
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -236,7 +204,8 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -253,20 +222,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Shared with prod-rollback so the two paths render an identical bot env.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
|
|
||||||
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
|
||||||
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
|
|
||||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -51,13 +51,32 @@ jobs:
|
|||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
|
||||||
|
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||||
|
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
|
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
|
||||||
|
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||||
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
||||||
|
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||||
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
|
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
||||||
INPUT_TARGET: ${{ inputs.target_version }}
|
INPUT_TARGET: ${{ inputs.target_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -89,24 +108,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
cat > stage/env.sh <<EOF
|
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
|
||||||
export REGISTRY='$REGISTRY'
|
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
|
||||||
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
|
||||||
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
|
||||||
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
|
||||||
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
|
||||||
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
|
||||||
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
|
||||||
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
|
||||||
export APP_VERSION='$TARGET'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -147,9 +151,11 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
||||||
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
steps:
|
steps:
|
||||||
@@ -163,19 +169,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
|
|
||||||
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
|
||||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
+18
-6
@@ -1,6 +1,10 @@
|
|||||||
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
||||||
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
||||||
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
# deploy maps the PROD_-prefixed set the same way. Values that are identical on every
|
||||||
|
# contour (DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS/USER/PASS, GRAFANA_SMTP_PORT,
|
||||||
|
# VITE_VK_APP_LINK/ID, the two VK secrets) live as ONE unprefixed Gitea entry, and the
|
||||||
|
# deploy derives TELEGRAM_MINIAPP_URL / GRAFANA_ROOT_URL / VITE_VK_ID_REDIRECT_URL from
|
||||||
|
# PUBLIC_BASE_URL. Copy to deploy/.env for a local run (set the derived ones directly).
|
||||||
#
|
#
|
||||||
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
||||||
|
|
||||||
@@ -15,8 +19,9 @@ POSTGRES_PASSWORD=change-me # required
|
|||||||
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
||||||
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
||||||
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
|
||||||
DICT_VERSION=v1.3.0
|
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
|
||||||
|
DICT_VERSION=v1.3.1
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
@@ -69,11 +74,11 @@ VITE_TELEGRAM_LINK= # friend-invite Mini App lin
|
|||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||||
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri
|
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri. Deploy derives it as PUBLIC_BASE_URL + /app/
|
||||||
VITE_GATEWAY_URL=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Grafana ----------------------------------------------------------------
|
# --- Grafana ----------------------------------------------------------------
|
||||||
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
|
GRAFANA_ROOT_URL=/_gm/grafana/ # deploy derives PUBLIC_BASE_URL + /_gm/grafana/; set the full https URL for a local run
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# --- Telegram validator + bot -----------------------------------------------
|
# --- Telegram validator + bot -----------------------------------------------
|
||||||
@@ -90,7 +95,7 @@ TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; emp
|
|||||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
||||||
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||||
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
@@ -103,3 +108,10 @@ GATEWAY_VK_APP_SECRET=
|
|||||||
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
||||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
|
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
||||||
|
# somewhere an attacker would find it; empty disables the trap. Gitea
|
||||||
|
# TEST_/PROD_GATEWAY_HONEYTOKEN (secret).
|
||||||
|
GATEWAY_HONEYTOKEN=
|
||||||
|
|||||||
+66
-32
@@ -46,6 +46,18 @@ runs `docker compose up -d --build` on the runner host. The prod deploy maps the
|
|||||||
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
||||||
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
||||||
|
|
||||||
|
Three naming classes in Gitea:
|
||||||
|
- **Per-contour** (`TEST_`/`PROD_<NAME>`) — values that differ between the contours
|
||||||
|
(bots, hosts, public origin, log level, email senders): the common case below.
|
||||||
|
- **Shared** (one unprefixed `<NAME>`, no prefix) — values identical on every contour,
|
||||||
|
stored once: `DICT_VERSION`, `SMTP_RELAY_HOST`/`PORT`/`TLS`/`USER`/`PASS`,
|
||||||
|
`GRAFANA_SMTP_PORT`, `VITE_VK_APP_LINK`, `VITE_VK_APP_ID`, `GATEWAY_VK_APP_SECRET`,
|
||||||
|
`GATEWAY_VK_ID_CLIENT_SECRET` (one Selectel relay + one pair of VK apps for all contours).
|
||||||
|
- **Derived** — not stored at all; the deploy computes them from `PUBLIC_BASE_URL`
|
||||||
|
(`deploy/write-prod-env.sh`, and the `ci.yaml` deploy step): `TELEGRAM_MINIAPP_URL`
|
||||||
|
(`+ /telegram/`), `GRAFANA_ROOT_URL` (`+ /_gm/grafana/`), `VITE_VK_ID_REDIRECT_URL`
|
||||||
|
(`+ /app/`). Set them directly only for a local `.env` run.
|
||||||
|
|
||||||
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
||||||
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
||||||
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
||||||
@@ -62,7 +74,7 @@ compose binds from this directory.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
@@ -82,11 +94,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
||||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
|
| `GRAFANA_ROOT_URL` | derived | `/_gm/grafana/` | Grafana root URL (sub-path serving). The deploy derives `PUBLIC_BASE_URL + /_gm/grafana/`; set the full `https://<domain>/_gm/grafana/` only for a local run. |
|
||||||
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
||||||
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
|
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
|
||||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||||
@@ -94,23 +106,25 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||||
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
|
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||||
| `VITE_VK_APP_ID` | variable | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. |
|
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||||
| `VITE_VK_ID_REDIRECT_URL` | variable | _(empty)_ | VK ID trusted redirect URL (e.g. `https://erudit-game.ru/app/`) — must match the app's registered redirect exactly. Used by the SPA and as the gateway's exchange `redirect_uri`. |
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). |
|
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
||||||
|
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
||||||
|
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
| `SMTP_RELAY_TLS` | variable | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
| `SMTP_RELAY_TLS` | variable (shared) | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
||||||
| `SMTP_RELAY_USER` | secret | _(empty)_ | Relay SMTP AUTH username. |
|
| `SMTP_RELAY_USER` | secret (shared) | _(empty)_ | Relay SMTP AUTH username. |
|
||||||
| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. |
|
| `SMTP_RELAY_PASS` | secret (shared) | _(empty)_ | Relay SMTP AUTH password. |
|
||||||
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
||||||
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
||||||
| `SMTP_RELAY_ADMIN_FROM` | variable | _(empty)_ | Backend operator-alert sender (new feedback / word complaints), distinct from the confirm-code From. Empty (with `ADMIN_EMAIL`) disables the alert worker. |
|
| `SMTP_RELAY_ADMIN_FROM` | variable | _(empty)_ | Backend operator-alert sender (new feedback / word complaints), distinct from the confirm-code From. Empty (with `ADMIN_EMAIL`) disables the alert worker. |
|
||||||
| `ADMIN_EMAIL` | variable | _(empty)_ | Backend operator-alert recipient(s); several comma-separated addresses allowed. |
|
| `ADMIN_EMAIL` | variable | _(empty)_ | Backend operator-alert recipient(s); several comma-separated addresses allowed. |
|
||||||
| `SMTP_RELAY_SERVICE_FROM` | variable | _(empty)_ | Grafana infra-alert sender address. |
|
| `SMTP_RELAY_SERVICE_FROM` | variable | _(empty)_ | Grafana infra-alert sender address. |
|
||||||
| `SERVICE_EMAIL` | variable | _(empty)_ | Grafana infra-alert recipient(s); comma-separated allowed (read by the provisioned contact point via `$__env{SERVICE_EMAIL}`). |
|
| `SERVICE_EMAIL` | variable | _(empty)_ | Grafana infra-alert recipient(s); comma-separated allowed (read by the provisioned contact point via `$__env{SERVICE_EMAIL}`). |
|
||||||
| `GRAFANA_SMTP_PORT` | variable | _(empty)_ | STARTTLS port Grafana dials on `SMTP_RELAY_HOST` (its client can't do the backend's implicit TLS), e.g. Selectel's `1126`. Reuses `SMTP_RELAY_HOST`/`USER`/`PASS`. |
|
| `GRAFANA_SMTP_PORT` | variable (shared) | _(empty)_ | STARTTLS port Grafana dials on `SMTP_RELAY_HOST` (its client can't do the backend's implicit TLS), e.g. Selectel's `1126`. Reuses `SMTP_RELAY_HOST`/`USER`/`PASS`. |
|
||||||
| `GF_SMTP_ENABLED` | variable | `false` | Set `true` to enable Grafana alert emails. |
|
| `GF_SMTP_ENABLED` | variable | `false` | Set `true` to enable Grafana alert emails. |
|
||||||
|
|
||||||
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||||
@@ -141,14 +155,13 @@ intentionally **unset** — caddy owns `/_gm` in the contour.
|
|||||||
|
|
||||||
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
||||||
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
||||||
a build-time input with **no default** in the images, so it is set in exactly two places to
|
a build-time input with **no default** in the images. It is a single Gitea repo variable —
|
||||||
move the whole stack — change both to a new release:
|
**`DICT_VERSION`** (unprefixed, shared across contours) — so a release bump is **one edit**:
|
||||||
|
|
||||||
1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
|
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
|
||||||
download that dawg).
|
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
|
||||||
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
|
||||||
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
bakes a **fresh** volume's seed.
|
||||||
`DICT_VERSION`).
|
|
||||||
|
|
||||||
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
||||||
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||||
@@ -177,6 +190,16 @@ public site. After `master` is green this workflow is the **only** thing that to
|
|||||||
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
|
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
|
||||||
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
|
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
|
||||||
|
|
||||||
|
**Maintenance page.** During the roll (and any migration window) `prod-deploy.sh` raises a
|
||||||
|
flag the edge caddy serves a static 503 "технические работы" page from
|
||||||
|
(`deploy/caddy/maintenance.html`), for the user-facing routes only — `/_gm` (Grafana) stays
|
||||||
|
reachable. The flag is cleared on any exit (success, a health failure + rollback, or an
|
||||||
|
error) by a shell trap, so it can never stick on. It arms from this feature's own deploy
|
||||||
|
onward (the caddy carrying the gate must be live first). This is **not** a zero-downtime
|
||||||
|
deploy — the backend is a single stateful instance (in-memory game state + push hub, no
|
||||||
|
Redis), so its swap still blips (clients auto-reconnect the live stream); the page just
|
||||||
|
makes the window graceful instead of raw 502s.
|
||||||
|
|
||||||
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
|
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
|
||||||
`git describe --tags` into every image tag, every binary (`-ldflags` → `pkg/version` →
|
`git describe --tags` into every image tag, every binary (`-ldflags` → `pkg/version` →
|
||||||
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||||
@@ -209,20 +232,31 @@ together with the fresh CA.
|
|||||||
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
||||||
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
|
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
|
||||||
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
||||||
players arrive.
|
players arrive. The per-container memory caps are enforced (Compose v2 → cgroup), so no one
|
||||||
|
service can eat all RAM, but they **overcommit** the host (~2.8 GiB of caps vs 1.9 GiB) — a
|
||||||
|
**1 GiB swap file** (Ansible `common` role, `swap_size`, `vm.swappiness=10`) cushions a
|
||||||
|
simultaneous spike into swap instead of the kernel OOM-killer. The `host_mem_low` Grafana
|
||||||
|
alert fires under 10% available.
|
||||||
|
|
||||||
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
**Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets:
|
||||||
|
`GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`;
|
||||||
|
variables: `DICT_VERSION, SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, GRAFANA_SMTP_PORT,
|
||||||
|
VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy** (not stored):
|
||||||
|
`TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL`.
|
||||||
|
|
||||||
|
**`PROD_` Gitea set** (per-contour, mirrors `TEST_`, mapped onto the unprefixed names above)
|
||||||
|
— secrets:
|
||||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
|
||||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
|
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
|
||||||
SMTP_RELAY_USER, SMTP_RELAY_PASS, GATEWAY_VK_ID_CLIENT_SECRET}`; variables:
|
BOTLINK_BOT_KEY}`; variables:
|
||||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
||||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
||||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK, VITE_VK_APP_ID, VITE_VK_ID_REDIRECT_URL,
|
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
||||||
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL,
|
GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only
|
||||||
SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates
|
||||||
GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`.
|
or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress).
|
||||||
|
|
||||||
## Host-side setup (outside this repo)
|
## Host-side setup (outside this repo)
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
|
|||||||
# the host's own containers (and any ad-hoc runs) rotate identically.
|
# the host's own containers (and any ad-hoc runs) rotate identically.
|
||||||
docker_log_max_size: "10m"
|
docker_log_max_size: "10m"
|
||||||
docker_log_max_file: "3"
|
docker_log_max_file: "3"
|
||||||
|
|
||||||
|
# Swap file as an OOM cushion. The per-container memory caps (docker-compose.prod.yml)
|
||||||
|
# sum to more than the tight main host's RAM (2 vCPU / 1.9 GiB), so a simultaneous
|
||||||
|
# spike could otherwise hit the kernel OOM-killer and it might pick postgres. A small
|
||||||
|
# swap absorbs the overshoot; swappiness stays low so swap is a cushion, not a hot
|
||||||
|
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
|
||||||
|
swap_size: "1G"
|
||||||
|
swap_swappiness: 10
|
||||||
|
|||||||
@@ -150,6 +150,57 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
|
||||||
|
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
|
||||||
|
|
||||||
|
- name: Check whether the swap file is already active
|
||||||
|
ansible.builtin.command: swapon --show=NAME --noheadings
|
||||||
|
register: swap_active
|
||||||
|
changed_when: false
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Allocate the swap file
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "fallocate -l {{ swap_size }} /swapfile"
|
||||||
|
creates: /swapfile
|
||||||
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
||||||
|
|
||||||
|
- name: Secure the swap file (0600)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /swapfile
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Format and enable the swap file
|
||||||
|
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
|
||||||
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
||||||
|
|
||||||
|
- name: Persist the swap file in /etc/fstab
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
line: "/swapfile none swap sw 0 0"
|
||||||
|
regexp: '^/swapfile\s'
|
||||||
|
state: present
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Keep swap a cushion, not a hot path (low swappiness)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/sysctl.d/60-scrabble-swap.conf
|
||||||
|
mode: "0644"
|
||||||
|
content: "vm.swappiness = {{ swap_swappiness }}\n"
|
||||||
|
register: swappiness_conf
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Apply swappiness now
|
||||||
|
ansible.builtin.command: sysctl --system
|
||||||
|
changed_when: false
|
||||||
|
when: swap_size != "0" and swappiness_conf is changed
|
||||||
|
|
||||||
# --- Deploy directories --------------------------------------------------------
|
# --- Deploy directories --------------------------------------------------------
|
||||||
|
|
||||||
- name: Create the scrabble base directories
|
- name: Create the scrabble base directories
|
||||||
|
|||||||
@@ -33,6 +33,39 @@
|
|||||||
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
||||||
header Alt-Svc clear
|
header Alt-Svc clear
|
||||||
|
|
||||||
|
# Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by
|
||||||
|
# prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static
|
||||||
|
# 503 "works in progress" page instead of proxying to a mid-recreate upstream —
|
||||||
|
# a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so
|
||||||
|
# Grafana stays reachable during the window. Caddy stat()s the flag per request, so
|
||||||
|
# toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted
|
||||||
|
# read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag
|
||||||
|
# (only prod-deploy.sh does), so this is inert there.
|
||||||
|
@maintenance {
|
||||||
|
not path /_gm /_gm/*
|
||||||
|
file {
|
||||||
|
root /srv/maint
|
||||||
|
try_files on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle @maintenance {
|
||||||
|
error 503
|
||||||
|
}
|
||||||
|
handle_errors {
|
||||||
|
@maint503 expression {err.status_code} == 503
|
||||||
|
handle @maint503 {
|
||||||
|
root * /srv/maint
|
||||||
|
rewrite * /maintenance.html
|
||||||
|
header Retry-After 120
|
||||||
|
# Distinctive maintenance marker so the SPA can tell a planned window apart from
|
||||||
|
# a transient upstream 503 and show its own dimmed overlay (an in-session user
|
||||||
|
# never sees this static page — it only renders on a fresh load). The header
|
||||||
|
# rides every gated response, incl. the Connect/gRPC edge the app polls.
|
||||||
|
header X-Scrabble-Maintenance 1
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
||||||
@gm path /_gm /_gm/*
|
@gm path /_gm /_gm/*
|
||||||
handle @gm {
|
handle @gm {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Served with 503 by the edge caddy while the deploy holds the maintenance flag
|
||||||
|
(/srv/maint/on, toggled by deploy/prod-deploy.sh around a rolling swap). Static and
|
||||||
|
self-contained (no upstream, no external assets) so it renders while the backend /
|
||||||
|
gateway are mid-recreate.
|
||||||
|
-->
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>Эрудит — технические работы</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f4f1ea; color: #2b2b2b;
|
||||||
|
padding: 24px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { body { background: #1d1b17; color: #e8e4da; } }
|
||||||
|
.card { max-width: 30rem; text-align: center; line-height: 1.5; }
|
||||||
|
.tile {
|
||||||
|
display: inline-block; width: 3.5rem; height: 3.5rem; line-height: 3.5rem;
|
||||||
|
font-size: 2rem; font-weight: 700; border-radius: 10px;
|
||||||
|
background: #d9b451; color: #2b2b2b; box-shadow: 0 2px 4px rgba(0,0,0,.25);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 .5rem; }
|
||||||
|
p { margin: .35rem 0; }
|
||||||
|
.en { opacity: .7; font-size: .95rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="tile">Э</div>
|
||||||
|
<h1>Технические работы</h1>
|
||||||
|
<p>Идёт короткое обновление игры. Мы скоро вернёмся — обновите страницу через пару минут.</p>
|
||||||
|
<p class="en">Scrabble is briefly down for an update. Please refresh in a couple of minutes.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -227,10 +227,12 @@ services:
|
|||||||
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
||||||
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
# 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
|
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
|
||||||
# real in prod — the test contour arrives as one shared NAT address, so a ban
|
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
||||||
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
|
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
||||||
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
|
# 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_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
@@ -426,6 +428,11 @@ services:
|
|||||||
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ${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
|
- caddy-data:/data
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
|
|||||||
PG_USER="${POSTGRES_USER:-scrabble}"
|
PG_USER="${POSTGRES_USER:-scrabble}"
|
||||||
PG_DB="${POSTGRES_DB:-scrabble}"
|
PG_DB="${POSTGRES_DB:-scrabble}"
|
||||||
|
|
||||||
|
# Edge maintenance page. caddy serves a static 503 "works in progress" page for the
|
||||||
|
# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it
|
||||||
|
# across the roll / migration window and clear it on ANY exit — success, a health
|
||||||
|
# failure + rollback, or an unexpected error — via the trap, so users get a graceful
|
||||||
|
# page instead of raw mid-swap 502s and the flag can never get stuck on.
|
||||||
|
MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}"
|
||||||
|
maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; }
|
||||||
|
trap maint_off EXIT
|
||||||
|
|
||||||
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
||||||
export REGISTRY
|
export REGISTRY
|
||||||
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
||||||
@@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Existing stack: raise the maintenance page for the whole roll (and any migration
|
||||||
|
# window). It shows once caddy carries the gate (from this feature's own deploy
|
||||||
|
# onward); the EXIT trap lowers it however this run ends.
|
||||||
|
mkdir -p "$(dirname "$MAINT_FLAG")"
|
||||||
|
: > "$MAINT_FLAG"
|
||||||
|
echo "maintenance page raised ($MAINT_FLAG)"
|
||||||
|
|
||||||
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
||||||
if [ "$MIGRATION" = 1 ]; then
|
if [ "$MIGRATION" = 1 ]; then
|
||||||
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render the prod bot-host env.bot.sh from the workflow job environment.
|
||||||
|
#
|
||||||
|
# Sourced identically by prod-deploy (deploy-bot) and prod-rollback
|
||||||
|
# (rollback-bot) so the two paths cannot drift (see deploy/write-prod-env.sh
|
||||||
|
# for the same rationale on the main host).
|
||||||
|
#
|
||||||
|
# Usage: BOT_IMAGE=<image ref> bash deploy/write-prod-bot-env.sh <out-path>
|
||||||
|
#
|
||||||
|
# Every other value comes from the caller's environment (the job `env:` block).
|
||||||
|
out="${1:?usage: write-prod-bot-env.sh <out-path>}"
|
||||||
|
|
||||||
|
# The bot's Mini App URL is the same public origin the SPA serves; derive it
|
||||||
|
# rather than storing a second copy.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
|
||||||
|
cat > "$out" <<EOF
|
||||||
|
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
||||||
|
export BOT_IMAGE='$BOT_IMAGE'
|
||||||
|
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
||||||
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
||||||
|
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
||||||
|
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
|
||||||
|
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
||||||
|
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
||||||
|
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
||||||
|
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||||
|
EOF
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render the prod main-host runtime env.sh from the workflow job environment.
|
||||||
|
#
|
||||||
|
# Sourced identically by prod-deploy (deploy-main) and prod-rollback
|
||||||
|
# (rollback-main) so the two paths cannot drift: a rollback recreates the
|
||||||
|
# containers, so it must re-render the SAME runtime env a full deploy does —
|
||||||
|
# otherwise transactional email, VK login and Grafana alerts silently go dark
|
||||||
|
# after a rollback until the next full deploy.
|
||||||
|
#
|
||||||
|
# Usage: APP_VERSION=<tag> bash deploy/write-prod-env.sh <out-path>
|
||||||
|
#
|
||||||
|
# Every other value comes from the caller's environment (the job `env:` block,
|
||||||
|
# vars.* / secrets.*). Mirrors the compose interpolation contract in
|
||||||
|
# deploy/.env.example; keep in sync with deploy/docker-compose{,.prod}.yml.
|
||||||
|
out="${1:?usage: write-prod-env.sh <out-path>}"
|
||||||
|
|
||||||
|
# Derive the public URLs from the one canonical origin instead of storing each
|
||||||
|
# under its own variable. The path suffixes are structural (SPA routes / the
|
||||||
|
# Caddy /_gm sub-path), identical on every contour.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
GRAFANA_ROOT_URL="$base/_gm/grafana/"
|
||||||
|
VITE_VK_ID_REDIRECT_URL="$base/app/"
|
||||||
|
|
||||||
|
# Grafana needs a BARE from-address (it rejects the "Name" <addr> form the backend
|
||||||
|
# go-mail accepts, and validates it even when SMTP is disabled — a bad value
|
||||||
|
# crash-loops Grafana). Split the display-format SERVICE From into address + name;
|
||||||
|
# the backend keeps the full form.
|
||||||
|
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
|
||||||
|
GRAFANA_SMTP_FROM_NAME=''
|
||||||
|
case "$svc_from" in
|
||||||
|
*"<"*">"*)
|
||||||
|
GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
|
||||||
|
GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
|
||||||
|
*) GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cat > "$out" <<EOF
|
||||||
|
export REGISTRY='$REGISTRY'
|
||||||
|
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
||||||
|
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
||||||
|
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
||||||
|
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
||||||
|
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
||||||
|
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
||||||
|
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
||||||
|
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
||||||
|
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||||
|
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||||
|
export DICT_VERSION='$DICT_VERSION'
|
||||||
|
export APP_VERSION='$APP_VERSION'
|
||||||
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
||||||
|
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||||
|
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||||
|
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||||
|
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
||||||
|
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
||||||
|
export SMTP_RELAY_TLS='$SMTP_RELAY_TLS'
|
||||||
|
export SMTP_RELAY_USER='$SMTP_RELAY_USER'
|
||||||
|
export SMTP_RELAY_PASS='$SMTP_RELAY_PASS'
|
||||||
|
export SMTP_RELAY_FROM='$SMTP_RELAY_FROM'
|
||||||
|
export SMTP_RELAY_ADMIN_FROM='$SMTP_RELAY_ADMIN_FROM'
|
||||||
|
export ADMIN_EMAIL='$ADMIN_EMAIL'
|
||||||
|
export SMTP_RELAY_SERVICE_FROM='$SMTP_RELAY_SERVICE_FROM'
|
||||||
|
export GRAFANA_SMTP_FROM_ADDRESS='$GRAFANA_SMTP_FROM_ADDRESS'
|
||||||
|
export GRAFANA_SMTP_FROM_NAME='$GRAFANA_SMTP_FROM_NAME'
|
||||||
|
export SERVICE_EMAIL='$SERVICE_EMAIL'
|
||||||
|
export GRAFANA_SMTP_PORT='$GRAFANA_SMTP_PORT'
|
||||||
|
export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
||||||
|
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||||
|
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
||||||
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance);
|
||||||
|
// the mock transport never produces one, so — like the offline indicator — the e2e drives it
|
||||||
|
// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside
|
||||||
|
// the route blocks in App.svelte), so it shows without a session.
|
||||||
|
test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A planned deploy window begins: a non-dismissable dimmed overlay covers the app.
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
|
||||||
|
const overlay = page.getByRole('alertdialog');
|
||||||
|
await expect(overlay).toBeVisible();
|
||||||
|
// The retry button is present (EN "Try again" / RU "Повторить" depending on locale).
|
||||||
|
await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The window ends — in prod the store's poll lifts it on the first successful read; the mock
|
||||||
|
// has no probe, so it clears explicitly. The overlay disappears with no page reload.
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off());
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Real recovery reloads the page (the deploy may have shipped an incompatible client, and the
|
||||||
|
// running bundle is the old one). Wait for the forced navigation, then confirm the app
|
||||||
|
// re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back.
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForEvent('load'),
|
||||||
|
page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()),
|
||||||
|
]);
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Coachmark from './components/Coachmark.svelte';
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -128,6 +129,7 @@
|
|||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
<Coachmark />
|
<Coachmark />
|
||||||
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Non-dismissable maintenance cover. Shown while the edge is in a planned deploy window
|
||||||
|
// (maintenance.svelte.ts, raised from a caddy 503 carrying X-Scrabble-Maintenance). It has
|
||||||
|
// no close affordance — an in-session user cannot dismiss it — but the store's poll lifts
|
||||||
|
// it automatically the moment the gateway answers again; the "retry" button just forces an
|
||||||
|
// immediate re-check. Mirrors the static caddy maintenance page (deploy/caddy/maintenance.html)
|
||||||
|
// so a fresh load and an in-session user see the same thing.
|
||||||
|
import { maintenance, retryNow } from '../lib/maintenance.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if maintenance.active}
|
||||||
|
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="maint-title" aria-describedby="maint-body">
|
||||||
|
<div class="card">
|
||||||
|
<div class="tile" aria-hidden="true">Э</div>
|
||||||
|
<h1 id="maint-title">{t('maintenance.title')}</h1>
|
||||||
|
<p id="maint-body">{t('maintenance.body')}</p>
|
||||||
|
<button type="button" onclick={retryNow}>{t('maintenance.retry')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above the game (z 60) and toasts (z 50) — a planned window covers everything the user
|
||||||
|
could otherwise interact with; below the dev DebugPanel (z 10000). */
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 22rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
/* Mirrors a placed board tile (as Splash.svelte / the caddy page do). */
|
||||||
|
.tile {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--tile-bg);
|
||||||
|
color: var(--tile-text);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
|
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
|
|||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
|
||||||
const isMock = import.meta.env.MODE === 'mock';
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
|
||||||
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
offline: reportOffline,
|
offline: reportOffline,
|
||||||
online: reportOnline,
|
online: reportOnline,
|
||||||
};
|
};
|
||||||
|
// The mock never produces a real 503, so the e2e drives the maintenance overlay directly
|
||||||
|
// (the store's poll is inert in mock — no probe is registered — so `off` clears it).
|
||||||
|
(
|
||||||
|
window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } }
|
||||||
|
).__maint = {
|
||||||
|
on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
|
||||||
|
off: clearMaintenance,
|
||||||
|
recover: maintenanceRecovered,
|
||||||
|
};
|
||||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const en = {
|
|||||||
'app.brand': 'Erudit',
|
'app.brand': 'Erudit',
|
||||||
'dict.loading': 'Loading…',
|
'dict.loading': 'Loading…',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
'maintenance.title': 'Under maintenance',
|
||||||
|
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||||
|
'maintenance.retry': 'Try again',
|
||||||
|
|
||||||
'blocked.title': 'Account blocked',
|
'blocked.title': 'Account blocked',
|
||||||
'blocked.permanent': 'Your account is blocked.',
|
'blocked.permanent': 'Your account is blocked.',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'app.brand': 'Эрудит',
|
'app.brand': 'Эрудит',
|
||||||
'dict.loading': 'Загрузка…',
|
'dict.loading': 'Загрузка…',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
'maintenance.title': 'Технические работы',
|
||||||
|
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||||
|
'maintenance.retry': 'Повторить',
|
||||||
|
|
||||||
'blocked.title': 'Учётная запись заблокирована',
|
'blocked.title': 'Учётная запись заблокирована',
|
||||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
||||||
|
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
||||||
|
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
||||||
|
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
||||||
|
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
||||||
|
// Mock mode never reports maintenance, so it simply stays inactive.
|
||||||
|
|
||||||
|
import { backoffMs } from './retry';
|
||||||
|
|
||||||
|
let active = $state(false);
|
||||||
|
let retryHintMs = $state(0);
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
let probing = false;
|
||||||
|
let probe: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
export const maintenance = {
|
||||||
|
/** active is true while the edge is in a planned maintenance window. */
|
||||||
|
get active(): boolean {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
||||||
|
get retryHintMs(): number {
|
||||||
|
return retryHintMs;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
||||||
|
* (the transport wires a cheap authenticated read). */
|
||||||
|
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
||||||
|
probe = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
||||||
|
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
||||||
|
export function reportMaintenance(retryAfterMs: number): void {
|
||||||
|
retryHintMs = retryAfterMs;
|
||||||
|
if (active) return;
|
||||||
|
active = true;
|
||||||
|
attempt = 0;
|
||||||
|
if (probe) schedulePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the
|
||||||
|
* e2e hook). Prod recovery goes through maintenanceRecovered instead. */
|
||||||
|
export function clearMaintenance(): void {
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** maintenanceRecovered runs when the edge answers again after a window we were showing.
|
||||||
|
* The deploy that ended the window may have shipped a client-incompatible change (a wire /
|
||||||
|
* schema bump), and the running bundle is the OLD one — so reload to pick up the fresh
|
||||||
|
* client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover
|
||||||
|
* stays over the brief reload flash. In prod recovery is the only way the overlay clears. */
|
||||||
|
export function maintenanceRecovered(): void {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (typeof location !== 'undefined') location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
||||||
|
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||||
|
export function retryNow(): void {
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
attempt = 0;
|
||||||
|
runProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(): void {
|
||||||
|
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProbe(): void {
|
||||||
|
pollTimer = null;
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
probing = true;
|
||||||
|
probe().then(
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
maintenanceRecovered();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
if (active) schedulePoll();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Code, ConnectError } from '@connectrpc/connect';
|
||||||
|
import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance';
|
||||||
|
|
||||||
|
// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response
|
||||||
|
// headers are preserved as `.metadata` (verified against @connectrpc/connect-web).
|
||||||
|
const err503 = (headers: Record<string, string>): ConnectError =>
|
||||||
|
new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers));
|
||||||
|
|
||||||
|
describe('maintenanceRetryMs', () => {
|
||||||
|
it('detects the marker and parses Retry-After', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a 503 without the marker (a plain transient failure)', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-Connect errors', () => {
|
||||||
|
expect(maintenanceRetryMs(new Error('boom'))).toBeNull();
|
||||||
|
expect(maintenanceRetryMs(undefined)).toBeNull();
|
||||||
|
expect(maintenanceRetryMs('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRetryAfterMs', () => {
|
||||||
|
it('clamps into the 3s–120s band', () => {
|
||||||
|
expect(parseRetryAfterMs('120')).toBe(120_000);
|
||||||
|
expect(parseRetryAfterMs('1')).toBe(3_000); // floor
|
||||||
|
expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default on absent / garbage / non-positive', () => {
|
||||||
|
expect(parseRetryAfterMs(null)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs(undefined)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('soon')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('0')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('-5')).toBe(15_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Detection of a planned edge maintenance window. During a prod deploy roll the edge
|
||||||
|
// caddy returns HTTP 503 with an `X-Scrabble-Maintenance: 1` header (and a `Retry-After`)
|
||||||
|
// for every gated route. The SPA keys STRICTLY on that marker — not on any transport
|
||||||
|
// `unavailable`, which the "Connecting…" indicator already covers — so a planned window
|
||||||
|
// raises the maintenance overlay while an ordinary blip stays a soft reconnect.
|
||||||
|
//
|
||||||
|
// These helpers are pure (no DOM, no runes) so they unit-test in the node vitest env. The
|
||||||
|
// maintenance marker + Retry-After ride on the raw ConnectError's `metadata`, which
|
||||||
|
// toGatewayError (retry.ts) discards — so detection must run on the raw error.
|
||||||
|
|
||||||
|
import { ConnectError } from '@connectrpc/connect';
|
||||||
|
|
||||||
|
const DEFAULT_RETRY_MS = 15_000;
|
||||||
|
const MIN_RETRY_MS = 3_000;
|
||||||
|
const MAX_RETRY_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parseRetryAfterMs converts a `Retry-After` header (delta-seconds — the edge emits
|
||||||
|
* seconds, never an HTTP-date) into a millisecond hint clamped to a sane band. An absent
|
||||||
|
* or unparseable value falls back to a default.
|
||||||
|
*/
|
||||||
|
export function parseRetryAfterMs(header: string | null | undefined): number {
|
||||||
|
const secs = header == null ? NaN : Number(header);
|
||||||
|
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_MS;
|
||||||
|
return Math.min(MAX_RETRY_MS, Math.max(MIN_RETRY_MS, Math.round(secs * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maintenanceRetryMs returns the maintenance `Retry-After` hint in milliseconds when the
|
||||||
|
* thrown transport error is the edge maintenance 503 (carries `X-Scrabble-Maintenance: 1`),
|
||||||
|
* or null for any other error. Header lookup is case-insensitive (Headers.get).
|
||||||
|
*/
|
||||||
|
export function maintenanceRetryMs(e: unknown): number | null {
|
||||||
|
if (!(e instanceof ConnectError)) return null;
|
||||||
|
if (e.metadata?.get('x-scrabble-maintenance') !== '1') return null;
|
||||||
|
return parseRetryAfterMs(e.metadata.get('retry-after'));
|
||||||
|
}
|
||||||
+24
-3
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
|
|||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
|
import { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
const MAX_RETRIES = 6;
|
const MAX_RETRIES = 6;
|
||||||
@@ -28,10 +30,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
|
|
||||||
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
registerProbe(async () => {
|
const reachabilityProbe = async (): Promise<void> => {
|
||||||
if (!token) throw new Error('no session');
|
if (!token) throw new Error('no session');
|
||||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
});
|
};
|
||||||
|
registerProbe(reachabilityProbe);
|
||||||
|
// The maintenance overlay reuses the same cheap read to poll for the end of a deploy
|
||||||
|
// window; the first success lifts the overlay (maintenance.svelte.ts).
|
||||||
|
registerMaintenanceProbe(reachabilityProbe);
|
||||||
|
|
||||||
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
||||||
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||||
@@ -43,6 +49,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
||||||
|
const maintMs = maintenanceRetryMs(e);
|
||||||
|
if (maintMs !== null) {
|
||||||
|
// A planned deploy window (the edge 503 carried X-Scrabble-Maintenance): raise the
|
||||||
|
// overlay and fail fast — its own poll drives recovery — instead of burning the
|
||||||
|
// retry budget on every read for the length of the window.
|
||||||
|
reportMaintenance(maintMs);
|
||||||
|
throw toGatewayError(e);
|
||||||
|
}
|
||||||
const err = toGatewayError(e);
|
const err = toGatewayError(e);
|
||||||
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||||
reportOffline();
|
reportOffline();
|
||||||
@@ -53,6 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
reportOnline();
|
reportOnline();
|
||||||
|
// A read got through: if the maintenance overlay was up, the deploy window has ended —
|
||||||
|
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
|
||||||
|
maintenanceRecovered();
|
||||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
@@ -310,7 +327,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
if (pe) onEvent(pe);
|
if (pe) onEvent(pe);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!ctrl.signal.aborted) onError?.(toGatewayError(e));
|
if (!ctrl.signal.aborted) {
|
||||||
|
const maintMs = maintenanceRetryMs(e);
|
||||||
|
if (maintMs !== null) reportMaintenance(maintMs);
|
||||||
|
onError?.(toGatewayError(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
|
|||||||
Reference in New Issue
Block a user