diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index dcaa741..4aad6a0 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,12 +26,12 @@ on: push: branches: [development] -# The dictionary release the test suite validates against — the current -# scrabble-dictionary release. Centralised here so a release bump is one edit; the -# unit/integration jobs inherit it. The deploy job overrides it per contour with -# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. +# The dictionary release. One Gitea variable is the single source of truth: the +# test suite validates against it here (inherited by the unit/integration jobs) and +# both contours' deploy jobs seed a fresh volume with the same value. A release bump +# is one edit (the variable). See deploy/README.md. env: - DICT_VERSION: v1.3.1 + DICT_VERSION: ${{ vars.DICT_VERSION }} jobs: # changes detects which areas a PR/push touched, so the test jobs can skip when @@ -329,20 +329,22 @@ jobs: TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }} # 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. - 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 # for the server-side confidential code exchange — a SEPARATE VK app from the Mini - # App above. Empty leaves the link.vk.* ops disabled. - GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.TEST_GATEWAY_VK_ID_CLIENT_SECRET }} + # App above. One VK ID "Web" app serves every contour -> unprefixed secret. + GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} # Signs the finished-game export download URLs (backend + compose interpolation). EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }} - # Transactional email via the shared Selectel relay. Empty host leaves the - # backend on the log mailer (email disabled) but the contour still boots. - SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }} - SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }} - SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }} - SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }} - SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }} + # Transactional email via the shared Selectel relay: one account for every + # contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend + # on the log mailer (email disabled) but the contour still boots. + 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.TEST_SMTP_RELAY_FROM }} # Operator alerts: backend admin emails (new feedback / complaints) + Grafana # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS @@ -351,15 +353,15 @@ jobs: ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }} SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }} 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 }} # Canonical public origin for links in the email (this contour's URL); # required by the backend whenever SMTP_RELAY_HOST is set. PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} - GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} 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_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }} TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }} @@ -372,16 +374,16 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} - VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }} - # VK ID web login: the "Web" app id + its trusted redirect URL. One value each feeds - # both the SPA (the authorize URL) and the gateway (client_id / exchange redirect_uri). - VITE_VK_APP_ID: ${{ vars.TEST_VITE_VK_APP_ID }} - VITE_VK_ID_REDIRECT_URL: ${{ vars.TEST_VK_ID_REDIRECT_URL }} - VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} - # Unset vars render empty -> the compose ":-" defaults apply. + # VK Mini App landing link + VK ID "Web" app id: one value each serves every + # contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID); + # the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. + VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + # VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the + # compose ":-" empty default. Other unset vars likewise fall to their defaults. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }} - DICT_VERSION: ${{ vars.TEST_DICT_VERSION }} + DICT_VERSION: ${{ vars.DICT_VERSION }} LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }} run: | # Seed the config files to a stable host path. The runner checks out into @@ -394,6 +396,13 @@ jobs: mkdir -p "$conf" cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/ export SCRABBLE_CONFIG_DIR="$conf" + # 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" # 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 diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 0186e4d..884d747 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -40,15 +40,16 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} - VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }} - # VK ID web login: the "Web" app id + trusted redirect URL, baked into the SPA authorize URL. - VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }} - VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }} - VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }} + # VK Mini App link + VK ID "Web" app id: one value each serves every contour. + VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + # VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default). POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} - TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} - DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + # 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: - uses: actions/checkout@v4 with: @@ -62,6 +63,11 @@ jobs: working-directory: deploy run: | 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 bot separately, since it is profiled out of the prod compose. docker compose -f docker-compose.yml -f docker-compose.prod.yml build @@ -86,20 +92,21 @@ jobs: GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} - GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }} - # VK ID web login: the SPA app id + redirect URL (the gateway reuses them as its - # client_id / exchange redirect_uri at runtime) and the "Web" app's protected key. - VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }} - VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }} - GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.PROD_GATEWAY_VK_ID_CLIENT_SECRET }} + GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} + # VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at + # runtime) + the app's protected key. Both shared across contours. The redirect URL is + # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} - # Transactional email via the shared Selectel relay (confirm-codes). - SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }} - SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }} - SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }} - SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }} - SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }} + # Transactional email via the shared Selectel relay (confirm-codes): one account for + # every contour -> unprefixed host/port/tls/user/pass. + 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 }} # Operator alerts: backend admin emails + Grafana infra alerts (distinct senders + # recipients; Grafana uses the relay's STARTTLS host:port). @@ -107,20 +114,20 @@ jobs: 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.PROD_GRAFANA_SMTP_PORT }} + GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }} GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }} PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} - GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} - DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + DICT_VERSION: ${{ vars.DICT_VERSION }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} 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: - uses: actions/checkout@v4 with: @@ -148,56 +155,8 @@ jobs: run: | umask 077 mkdir -p stage/certs-main - # Grafana needs a BARE from-address (it rejects the "Name" form the backend - # accepts, and validates it even when disabled); split the display-format here. - 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 < stage/certs-main/ca.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 @@ -236,7 +195,8 @@ jobs: PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} 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_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }} @@ -253,20 +213,8 @@ jobs: run: | umask 077 mkdir -p stage/certs-bot - cat > stage/env.bot.sh < stage/certs-bot/ca.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 diff --git a/.gitea/workflows/prod-rollback.yaml b/.gitea/workflows/prod-rollback.yaml index 2e0a3fa..177907b 100644 --- a/.gitea/workflows/prod-rollback.yaml +++ b/.gitea/workflows/prod-rollback.yaml @@ -51,13 +51,31 @@ jobs: PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} - GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} - DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + DICT_VERSION: ${{ vars.DICT_VERSION }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} 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 }} + 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 }} steps: - uses: actions/checkout@v4 @@ -89,24 +107,9 @@ jobs: run: | umask 077 mkdir -p stage/certs-main - cat > stage/env.sh < the rollback re-renders the FULL + # runtime env (not a subset), so email / VK login / Grafana alerts survive it. + APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh 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_KEY" > stage/certs-main/gateway.key @@ -147,9 +150,11 @@ jobs: PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} 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_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_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} steps: @@ -163,19 +168,8 @@ jobs: run: | umask 077 mkdir -p stage/certs-bot - cat > stage/env.bot.sh < stage/certs-bot/ca.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 diff --git a/deploy/.env.example b/deploy/.env.example index e0f1a02..97a7d97 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,6 +1,10 @@ # 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 -# 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. @@ -15,8 +19,9 @@ POSTGRES_PASSWORD=change-me # required # 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 # recorded .seed_version marker wins — the seed-drift guard); change a running -# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). -DICT_VERSION=v1.3.0 +# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea +# variable (DICT_VERSION) seeds both contours + pins the CI test suite. +DICT_VERSION=v1.3.1 # --- Logging ---------------------------------------------------------------- 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_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app) 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= # --- 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 # --- 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_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_MINIAPP_URL= # required +TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/ TELEGRAM_TEST_ENV=false TELEGRAM_API_BASE_URL= diff --git a/deploy/README.md b/deploy/README.md index ba0872d..c129eef 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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` feeds the compose's `POSTGRES_PASSWORD`, etc. +Three naming classes in Gitea: +- **Per-contour** (`TEST_`/`PROD_`) — values that differ between the contours + (bots, hosts, public origin, log level, email senders): the common case below. +- **Shared** (one unprefixed ``, 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`, `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 @@ -62,7 +74,7 @@ compose binds from this directory. | --- | --- | --- | | `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 ''`. | -| `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`. | **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_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`). | | `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. | -| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https:///_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:///_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. | | `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/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,24 @@ 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_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me//` — `` 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_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app`). | -| `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_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`. | -| `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). | +| `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`). One VK Mini App for all contours. | +| `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` | 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_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. | | `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_PORT` | variable | `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_USER` | secret | _(empty)_ | Relay SMTP AUTH username. | -| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. | +| `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 (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | +| `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 (shared) | _(empty)_ | Relay SMTP AUTH username. | +| `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. | | `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. | | `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. | | `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. | The six `VITE_*` are **build-args** baked into the gateway and landing images at @@ -141,14 +154,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 [`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 -move the whole stack — change both to a new release: +a build-time input with **no default** in the images. It is a single Gitea repo variable — +**`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 - download that dawg). -2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag - the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as - `DICT_VERSION`). +- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level + `env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and +- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that + bakes a **fresh** volume's seed. 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 @@ -211,18 +223,24 @@ overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_expor host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when players arrive. -**`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, TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA, -BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY, -SMTP_RELAY_USER, SMTP_RELAY_PASS, GATEWAY_VK_ID_CLIENT_SECRET}`; variables: -`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, -GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, -TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, -VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK, VITE_VK_APP_ID, VITE_VK_ID_REDIRECT_URL, -SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL, -SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL, -GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`. +BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables: +`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL, +TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME, +VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM, +PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL, +GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only +infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates +or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress). ## Host-side setup (outside this repo) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3b0c440..97cbf1b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -227,10 +227,12 @@ services: GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt # Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the - # honeypot/honeytoken. Off by default: it bans by client IP, which is only - # real in prod — the test contour arrives as one shared NAT address, so a ban - # there would be self-inflicted (the honeypot/honeytoken still log). Prod sets - # these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap. + # honeypot/honeytoken. Off by default: it bans by client IP, which is only real + # in prod — the test contour arrives as one shared NAT address, so a ban there + # would be self-inflicted (the honeypot still logs). The prod deploy forces it on + # in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN + # is the planted bearer trap; it is currently unset on every contour (no Gitea entry + # feeds it), so the trap is inert until an operator wires a secret. GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false} GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/deploy/write-prod-bot-env.sh b/deploy/write-prod-bot-env.sh new file mode 100755 index 0000000..7f1c9d8 --- /dev/null +++ b/deploy/write-prod-bot-env.sh @@ -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= bash deploy/write-prod-bot-env.sh +# +# Every other value comes from the caller's environment (the job `env:` block). +out="${1:?usage: write-prod-bot-env.sh }" + +# 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" < bash deploy/write-prod-env.sh +# +# 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 }" + +# 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" 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" <