Compare commits
7 Commits
21a3a37a66
...
a4ac702907
| Author | SHA1 | Date | |
|---|---|---|---|
| a4ac702907 | |||
| af5dc5db4c | |||
| b9799bf091 | |||
| d66c5f8893 | |||
| 5087c67201 | |||
| 85f092ba14 | |||
| f105c1adff |
@@ -80,8 +80,10 @@ binance-market — это **не часть игры**, а **общий инфр
|
||||
| **k3s-кластер** | VM `192.168.76.120`, ns `game` | — |
|
||||
| **Игровой бэк (боевой)** | TS-шлюз `updown-relayer`, сервис `testfront-gateway-smart-updown-relayer:8895` | оба домена: `/api`, `/ws` |
|
||||
| **Домены** | `smart-updown.binom.pw` (Unity-игра), `testfront.binom.pw` (тех-дашборд) | один и тот же бэк |
|
||||
| **Фронт игры** | deployment `fun-game-front2` (nginx + Unity WebGL) | образ `images.binom.pw/fun/game-front2:<тег>` |
|
||||
| **Ingress-пути** | **`smart-updown-api`** — `/api`,`/ws` (ВНЕ helm); `fun-game-front2-…` — только `/` | ADR-010 |
|
||||
| **Статика игры (корень `smart-updown.binom.pw`)** | **nginx на Proxmox, LXC 152 `game-web` (192.168.76.152)** | `/opt/game/releases/<N>/`, активная — `/opt/game/current` (ADR-011) |
|
||||
| **Фронт игры (сборка)** | репозиторий `fun-game-front2` → образ `images.binom.pw/fun/game-front2:<тег>` | из образа извлекается статика и кладётся на nginx (`tools/proxmox-nginx/`) |
|
||||
| **Ingress-пути (куб)** | **`smart-updown-api`** — `/api`,`/ws` (ВНЕ helm); `fun-game-front2-…` — только `/` (уже не обслуживает домен) | ADR-010 |
|
||||
| **Маршрутизация корня** | внешний Traefik VDS: `/api`,`/ws` → куб `192.168.76.120`; всё остальное → nginx `192.168.76.152` | ADR-011 |
|
||||
| **Solana localnet** | LXC 151 на Proxmox `192.168.76.66` | RPC `192.168.76.181:8899` |
|
||||
| **Контракт (в цепи)** | `9ALs…S1fCpf` (фантики, 4 инструкции) | прод-игра работает тут |
|
||||
| **Реестр образов** | `images.binom.pw` | — |
|
||||
|
||||
@@ -156,3 +156,113 @@ helm upgrade fun-game-front2 binom/fun-game-front2 -n game \
|
||||
## Ссылки (дополнено 2026-09-13)
|
||||
- Цена: `docs/PRICE-NATS.md`, диаграмма `docs/diagrams/price-flow.puml`
|
||||
- **Безопасность: `docs/SECURITY.md` — чек-лист `SEC-1…SEC-15` (аудит живого стенда 2026-09-13)**
|
||||
|
||||
## ⭐ 2026-09-15 (2) — статика игры ушла из куба на nginx (LXC 152), версии каталогами
|
||||
|
||||
**Где мы:** корень `smart-updown.binom.pw` раздаёт **nginx на Proxmox (LXC 152 `game-web`,
|
||||
192.168.76.152)** со версионированием по каталогам; `/api` и `/ws` по-прежнему идут в куб
|
||||
на TS-шлюз отдельным router'ом внешнего Traefik. Решение и разбор — **ADR-011** в `docs/DECISIONS.md`.
|
||||
|
||||
**Как выпустить новую сборку игры (памятка — вместо helm upgrade фронта):**
|
||||
```bash
|
||||
# 1. CI репозитория fun-game-front2 собирает Unity WebGL и пушит образ
|
||||
# images.binom.pw/fun/game-front2:<N> (тег = номер релиза, как раньше)
|
||||
# 2. раскладываем на nginx и включаем как активную:
|
||||
ssh root@192.168.76.66 "pct exec 152 -- /opt/game/tools/publish-release.sh <N> --activate"
|
||||
# 3. проверяем ДОМЕН (не helm):
|
||||
curl -sI https://smart-updown.binom.pw/ | grep -iE 'server|last-modified' # nginx/1.24.0 (Ubuntu)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://smart-updown.binom.pw/<N>/Build/WebGL.framework.js
|
||||
```
|
||||
Без `--activate` сборка просто ложится в `/opt/game/releases/<N>/` и доступна по `/<N>/` —
|
||||
корень не меняется. Откат версии = `publish-release.sh <старый N> --activate` (каталоги не удаляются).
|
||||
|
||||
**Инструменты:** `tools/proxmox-nginx/` — `unpack-release.sh` (распаковать dir-дамп образа),
|
||||
`publish-release.sh` (тянуть образ + разложить + переключить), `game.nginx.conf`,
|
||||
`smart-updown.traefik.yaml` (боевой конфиг внешнего Traefik). Скопированы в LXC 152
|
||||
в `/opt/game/tools/` и `/etc/nginx/sites-available/game`.
|
||||
|
||||
**Проверено живьём:** `/` → 200 `nginx/1.24.0 (Ubuntu)`; `/27/`,`/28/` → 200, ассеты версий
|
||||
различаются; `/api/state` → 200 JSON; `/api/ws` → цена идёт; сквозной `autoclose-check.cjs` — **9/9**.
|
||||
|
||||
**Задачи на будущее по этой схеме:**
|
||||
- [ ] **TODO** `[pump.meta]` Ссылки на версии — **только со слэшем** (`/28/`): голый `/28` даёт 301.
|
||||
Если понадобится бесслэшевый вид — добавить в nginx `return 301 /$build/`.
|
||||
- [ ] **TODO** `[pump.meta]` Чистка старых сборок на 152: каталоги `releases/<N>` не удаляются сами
|
||||
(~68 МБ на сборку). Политика ретеншена (сколько версий держим) — за владельцем.
|
||||
- [ ] **TODO** `[pump.meta]` Автоматизировать шаг публикации в CI `fun-game-front2`: после push образа
|
||||
дёргать `publish-release.sh` на 152 (сейчас — ручной вызов одной командой).
|
||||
- [ ] **TODO** `[pump.meta]` Уточнить, нужен ли под `fun-game-front2` в кубе вообще (домен он больше
|
||||
не обслуживает) — либо оставить как «предыдущая схема на откат», либо выключить.
|
||||
|
||||
## ⭐ 2026-09-15 (3) — выпущена сборка 29 + закрыта кэш-мина (редирект корня)
|
||||
|
||||
**Сборка 29 опубликована и активна.** Релиз Gitea `29` (id 1046) на коммите `25f4637`
|
||||
(«fix(webgl): не ронять плеер на старте из-за дыма падения и PC URP») → CI run #29 `success`
|
||||
(~8 мин) → образ `images.binom.pw/fun/game-front2:29` → `publish-release.sh 29 --activate`.
|
||||
|
||||
**Поймана и исправлена реальная мина (ADR-011-доп).** Корень раздавал файлы напрямую, и ассеты Unity
|
||||
(которые `index.html` зовёт ОТНОСИТЕЛЬНО — `buildUrl = "Build"`) уходили по URL **без номера сборки**
|
||||
`/Build/WebGL.wasm` под `immutable` 7д. Игрок неделю получал бы старый билд. **Фикс:** корень теперь
|
||||
**302 → `/29/`**; ассеты идут под номером версии; `immutable` сужен до `~^/[0-9]+/…`.
|
||||
|
||||
**Проверено живьём:** `/` → 302 → `/29/`; браузер приземляется на `/29/` и тянет `/29/Build/*` — все
|
||||
200, ошибок в консоли нет; `/27/`,`/28/` живы; `/api/*` и `/solana-rpc/` → 200; `autoclose-check` **9/9**;
|
||||
`testfront` цел.
|
||||
|
||||
**Памятка на будущее — выпуск сборки целиком:**
|
||||
```bash
|
||||
# 1. релиз в Gitea на нужном коммите (CI срабатывает ТОЛЬКО на release, не на push):
|
||||
curl -s -u subochev:PASS -X POST "https://git.binom.pw/api/v1/repos/subochev/fun-game-front2/releases" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tag_name":"30","name":"30","target_commitish":"<sha>","draft":false}'
|
||||
# 2. дождаться прогона (обычно ~8 мин):
|
||||
# tools/wait-ci-run.sh (в фоне, печатает итог)
|
||||
# 3. разложить на nginx и сделать активной:
|
||||
ssh root@192.168.76.66 "pct exec 152 -- /opt/game/tools/publish-release.sh 30 --activate"
|
||||
# 4. проверить ДОМЕН:
|
||||
curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' https://smart-updown.binom.pw/ # 302 -> /30/
|
||||
```
|
||||
|
||||
**TODO:**
|
||||
- [ ] `[pump.meta]` Автоматизировать шаг 3 (сейчас ручная команда после CI).
|
||||
- [ ] `[pump.meta]` Ретеншен старых сборок на 152 (сейчас 27, 28, 29 = ~200 МБ; политика за владельцем).
|
||||
|
||||
## ⭐ 2026-09-15 (4) — именованная сборка `sol` из zip + два дефекта в ней
|
||||
|
||||
**Сделано:** фронтендер прислал `build_sol.zip` (вне релизного цикла, без номера). Выложил под именем
|
||||
`sol` → **https://smart-updown.binom.pw/sol/** (см. ADR-012, `tools/proxmox-nginx/publish-zip.sh`).
|
||||
Активная сборка не тронута — корень по-прежнему `/29/`. Адрес: `/sol/`.
|
||||
|
||||
**Правки в nginx (иначе не работало):**
|
||||
- локейшн сборок расширен с чисел до имён → **обязательно вернуть `^~` служебным путям**
|
||||
(`/api`, `/ws`, `/solana-rpc/`), иначе расширенный regex перехватывает `/api/...` и ломает бэк;
|
||||
- `immutable` — только для номерных каталогов; именованные кэшировать нельзя (могут перезалить);
|
||||
- в маску добавлен `unityweb` (сборки фронтендера на `.unityweb`-ассетах).
|
||||
|
||||
**⚠️ Два дефекта в присланной сборке (отдать фронтендеру):**
|
||||
- [ ] **`[pump.front]` Двойной префикс API: игра зовёт `/api/api/state` → 404.**
|
||||
`SmartUpdownConfig.ApiBase()` для WebGL = `Origin()+"/api"`, а `BackendClient.HttpGetAsync("/api/state")`
|
||||
добавляет `/api/...` ещё раз. Лечится в одном месте: либо `ApiBase()` без `/api`, либо пути без `/api`.
|
||||
- [ ] **`[pump.front]` `ReferenceError: getProvider is not defined`** — падение при подключении Phantom.
|
||||
В `framework.js` есть вызовы `getProvider()`/`errMsg()`/`loadWeb3()`, но нет определений: в
|
||||
`Assets/Plugins/WebGL/SolanaBridge.jslib` они объявлены внутри IIFE, а через `mergeInto` экспортированы
|
||||
только `SolanaBridge*`-методы. Не регресс от nginx — воспроизводится и на сборке 29.
|
||||
|
||||
**Важное подтверждение:** билд `sol` в браузере **дошёл до игрового цикла** (`Phase -> Idle` →
|
||||
`Phase -> Approach`) — раньше в headless ни одна сборка так далеко не шла. Статика с nginx отдаётся корректно.
|
||||
|
||||
## 2026-09-15 (5) — исследование: редакторная сборка vs наша Docker/CI (ничего не меняли)
|
||||
|
||||
**Вердикт: Docker не виноват.** Разница — наш `BuildScript.cs` насильно ставит
|
||||
`WebGLCompressionFormat.Disabled`; фронт собирает с **Brotli + Decompression Fallback** (отсюда
|
||||
`.unityweb`). Было в браузер 26 МБ (52 МБ wasm + gzip на лету), стало бы 16.4 МБ. Код в обеих сборках
|
||||
эквивалентен (маркеры в wasm совпадают), версия Unity одинаковая (6000.3.16f1). Наш конвейер
|
||||
(GameCI-образ + `-batchmode -executeMethod`) — стандартная практика, не костыль. См. ADR-013.
|
||||
|
||||
**⚠️ Важная поправка:** оба дефекта (`/api/api/state` → 404 и `getProvider is not defined`) есть и в
|
||||
НАШЕЙ сборке 29 — это исходники, а не «его сборка». Задача переехала в кодовую правку.
|
||||
|
||||
- [ ] решить: переводить ли сборку на Brotli + Decompression Fallback (или gzip_static) — **ждём владельца**
|
||||
- [ ] `[pump.front]` убрать двойной `/api` в `BackendClient`/`SmartUpdownConfig`
|
||||
- [ ] `[pump.front]` `getProvider`: экспортировать хелперы jslib наружу (или инлайнить)
|
||||
- [ ] `[pump.ops]` `gzip off` для `.unityweb` в nginx LXC 152 (сейчас жмёт уже сжатое)
|
||||
|
||||
@@ -203,3 +203,199 @@
|
||||
(отдать фронтендеру, см. `TASKS.md`).
|
||||
|
||||
---
|
||||
|
||||
## ADR-011 — Статика игры вынесена из куба на nginx (LXC 152), версии — каталогами (2026-09-15)
|
||||
|
||||
**Контекст.** До этого и корень, и `/api`,`/ws` жили в кубе: внешний Traefik (VDS) вёл весь домен
|
||||
`smart-updown.binom.pw` на k3s `192.168.76.120`, статику отдавал под `fun-game-front2`
|
||||
(nginx внутри образа), API — отдельный ingress `smart-updown-api` → TS-шлюз `:8895`.
|
||||
Владелец решил **разделить плоскости**: бэк честно деплоится в куб, а сама игра (статика)
|
||||
раздаётся с nginx, развёрнутого на Proxmox — со **версионированием по каталогам** (`/<номер>/`).
|
||||
|
||||
**Решение:**
|
||||
1. **nginx на Proxmox** — новый LXC **152** (`game-web`, 192.168.76.152, Ubuntu 24.04, 2 ядра,
|
||||
1 ГБ RAM, диск 8 GB на `local-lvm`, `onboot=1`, unprivileged). Отдельный LXC потому, что
|
||||
статика игры — «10 строк конфига и каталог», держать её в кубе рядом с бэком смысла нет.
|
||||
2. **Версионирование каталогами:**
|
||||
- `/opt/game/releases/<N>/` — файлы сборки N (`Build/`, `TemplateData/`, `index.html`);
|
||||
- `/opt/game/current` — симлинк на активную сборку;
|
||||
- `https://smart-updown.binom.pw/` → активная сборка, `/28/…` → конкретная сборка.
|
||||
3. **Маршрутизация на внешнем Traefik (VDS)** — два router'а на один host, вместо одного:
|
||||
- `smart-updown-api-router`: `Host(...) && (PathPrefix('/api') || PathPrefix('/ws'))` → `192.168.76.120` (куб);
|
||||
- `smart-updown-router`: `Host(...)` → `192.168.76.152` (nginx на проксмоксе).
|
||||
PathPrefix-роутер специфичнее Host-роутера, поэтому `/api`,`/ws` **не перехватываются статикой**.
|
||||
IP LXC 152 достижим с VDS через wg-туннель (192.168.76.0/22 dev wg0).
|
||||
4. **В кубе ничего не менялось**: `ingress smart-updown-api` (`/api`,`/ws` → шлюз) как был, вне helm.
|
||||
Под `fun-game-front2` остался жив, но домен он больше не обслуживает (статика ушла на nginx) —
|
||||
в куб его релизить больше не обязательно; релизы — только доставка статики на nginx.
|
||||
5. **Инструменты** (`tools/proxmox-nginx/`): `unpack-release.sh` (распаковка dir-дампа образа в
|
||||
`releases/<N>`), `publish-release.sh` (тянет образ из реестра, раскладывает, по `--activate`
|
||||
переключает `current`), `game.nginx.conf`, `smart-updown.traefik.yaml`.
|
||||
|
||||
**Питфоллы, пойманные живьём:**
|
||||
- `skopeo copy docker://… dir:` кладёт слои по **sha256-digest**, а `manifest.json` — это **OCI-манифест**
|
||||
(словарь `layers[].digest`, не список строк как в docker-archiv). Парсить `json['layers'][i]['digest']`,
|
||||
а не печатать элемент целиком (иначе `tar: Cannot connect to {'mediaType': ...}`).
|
||||
- Голый шаблон `/28` (без слэша) отдаёт **301** — это `try_files ... /$build$subpath/`, т.е. редирект
|
||||
на `/28/`. Для относительных путей Unity (`src="Build/WebGL.loader.js"`) важно, чтобы **браузерный
|
||||
URL заканчивался слэшем**; иначе `Build/...` уедет в `/Build/...` и вернётся 404. Ссылаться на
|
||||
версии только со слэшем (`/28/`), либо ставить редирект в nginx (`return 301 /$build/`).
|
||||
- `/solana-rpc/` **не** перехватывается PathPrefix `/api` — это отдельный path, и на VDS он уходит
|
||||
в корневой router (nginx). Поэтому страховочный `location /solana-rpc/` в nginx нужен: старые сборки
|
||||
игры стучатся именно туда (проверено: `getHealth` → `ok` через домен).
|
||||
- Имена ассетов Unity внутри сборки не меняются (`WebGL.data`/`.wasm`/`.framework.js`), но теперь
|
||||
URL содержит номер сборки → `immutable`-кэш больше не «прилипает» между релизами (закрыт остаточный
|
||||
риск из ADR-010). Внутри одной версии ассеты неизменяемы, т.к. каталог версии не перезаписывается.
|
||||
|
||||
**Приёмка (живой домен, 2026-09-15):** корень `/` → 200 от `nginx/1.24.0 (Ubuntu)` (LXC 152);
|
||||
`/27/`,`/28/` → 200, ассеты версий различаются (md5 `WebGL.data`: `76b6f3…` vs `47453d…`);
|
||||
`/api/state` → 200 JSON (куб), `/api/ws` → `[open]` + `{"type":"price"}`; сквозной
|
||||
`tools/autoclose-check.cjs` на `BASE=https://smart-updown.binom.pw/api` — **9/9**; `testfront` не задет.
|
||||
Откат: вернуть `/opt/traefik/static/smart-updown.yaml` из бэкапа VDS
|
||||
(`/root/bak-smart-updown-nginx-2026-09-15/`) — домен снова целиком уйдёт в куб.
|
||||
|
||||
**Альтернативы:** (а) оставить статику в кубе и версионировать каталогами внутри образа — тянет
|
||||
пересборку образа на каждый билд игры и не решает вопрос кэша; (б) поднять в LXC не nginx, а
|
||||
podman-контейнер с nginx — лишний слой на 10 строках конфига; (в) отдавать статику прямо с k3s-Traefik
|
||||
по PVC — не то, о чём просил владелец (он хотел nginx на проксмоксе).
|
||||
|
||||
## ADR-011-доп — корень редиректит на активную сборку (закрыта кэш-мина ADR-010) (2026-09-15)
|
||||
|
||||
**Что нашли на сборке 29 (проверка живьём, не по документам).** `index.html` Unity ссылается на
|
||||
ассеты **относительным** путём (`var buildUrl = "Build"`). Пока корень отдавал файлы напрямую из
|
||||
`/opt/game/current`, игрок получал ассеты по URL **без номера сборки** — `/Build/WebGL.wasm`,
|
||||
`/Build/WebGL.data` и т.д. Эти URL между релизами **не меняются**, а в конфиге они попадали под
|
||||
маску `immutable` (7 суток). Итог: у игравшего игрока браузер неделю держал бы ассеты старой сборки
|
||||
и молча игнорировал новый билд. То есть моя собственная маска кэша воспроизводила ровно ту мину,
|
||||
которую ADR-011 объявлял закрытой. Версионирование каталогами **само по себе** её не лечило —
|
||||
лечило только то, чтобы URL ассетов реально содержали номер сборки.
|
||||
|
||||
**Решение.** Корень больше не раздаёт файлы — он **302-редиректит на активную сборку**:
|
||||
- `publish-release.sh --activate` пишет `/etc/nginx/game-active.inc`:
|
||||
`location = / { return 302 /<N>/; }` + то же для `/index.html`;
|
||||
- `include /etc/nginx/game-active.inc;` подключён в server-блок;
|
||||
- после редиректа `location.pathname` = `/29/`, относительный `buildUrl = "Build"` резолвится
|
||||
в `/29/Build/…` — версионированный URL, старый URL перестаёт использоваться вовсе;
|
||||
- маска `immutable` сужена до путей **с номером сборки** (`~^/[0-9]+/…`); всё остальное,
|
||||
включая неверсионированные ассеты, теперь `no-store` (страховка от отравления кэша).
|
||||
|
||||
**Проверено живьём (сборка 29):** `/` → **302 → `/29/`**; браузер приземляется на `/29/` и тянет
|
||||
`/29/Build/WebGL.wasm`, `/29/Build/WebGL.data`, `/29/Build/WebGL.framework.js` — все 200, ни одной
|
||||
ошибки в консоли; `/28/`,`/27/` живы; неверсионированный `/Build/WebGL.wasm` → 200 с `cache-control:
|
||||
no-store`; `/api/state`,`/api/price`,`/solana-rpc/` → 200; сквозная ставка `autoclose-check` — **9/9**;
|
||||
`testfront` не задет (200).
|
||||
|
||||
**Почему не «отдавать корень без редиректа».** Это работало бы (неверсионированные ассеты получили бы
|
||||
`no-store`), но теряется весь смысл `immutable`-кэширования: ассеты в 52 МБ + 18 МБ перекачивались бы
|
||||
при каждом заходе. Редирект даёт и корректность, и кэш.
|
||||
|
||||
**Остаточный риск.** Игрок, у которого в кэше уже лежат ассеты по **старому** неверсионированному URL
|
||||
(`/Build/WebGL.wasm`, полученные до этого фикса), один раз может получить старый билд — URL теперь
|
||||
`no-store`, так что после первой перезагрузки браузер сходит на сервер и получит свежее. Новые
|
||||
версии этой проблемы не имеют вовсе.
|
||||
|
||||
## ADR-012 — Именованные сборки (zip от фронтендера) рядом с номерными: `/sol/` (2026-09-15)
|
||||
|
||||
**Контекст.** Фронтендер прислал готовый Unity-WebGL-билд **zip-архивом** — вне релизного цикла Gitea
|
||||
(не через CI, без образа в реестре, без номера сборки). Владелец: «создай там сам папку и выложи».
|
||||
|
||||
**Решение.** Каталоги на nginx теперь двух видов, живут рядом:
|
||||
- `/opt/game/releases/<N>/` — номерные релизы из CI/реестра (`1`, `2`, … `29`);
|
||||
- `/opt/game/releases/<имя>/` — именованные сборки из zip (`sol`).
|
||||
|
||||
**Именованные сборки АКТИВНУЮ не трогают**: `current` остаётся на номере, корень по-прежнему
|
||||
редиректит на `/29/`. Именованная доступна только явной ссылкой `/<имя>/`.
|
||||
|
||||
**Изменения в nginx** (`game.nginx.conf`):
|
||||
- regex-локейшн сборок расширен с чисел до имён: `^/(?<build>[a-zA-Z0-9][a-zA-Z0-9_-]*)(?<subpath>/.*)?$`;
|
||||
- ⚠️ служебные пути получили префикс **`^~`** (`location ^~ /api`, `^~ /ws`, `^~ /solana-rpc/`).
|
||||
Без этого расширенный regex перехватывал бы `/api/...` как «сборку с именем api» — regex-локейшн
|
||||
в nginx приоритетнее обычного префиксного. `^~` подавляет проверку regex для этого префикса.
|
||||
**Это была реальная регрессия на первом применении: после расширения шаблона `/api/state` начал
|
||||
уходить в файловый резолвер.**
|
||||
- маска `immutable` осталась **только для номерных** каталогов (`~^/[0-9]+/…`): именованную папку
|
||||
могут перезалить тем же именем, поэтому кэшировать её ассеты на 7 суток нельзя. Добавлено
|
||||
расширение `unityweb` (сборки фронтендера используют `.unityweb`-ассеты).
|
||||
- новый скрипт `tools/proxmox-nginx/publish-zip.sh <имя> <путь.zip>` — распаковка, подъём
|
||||
единственной вложенной папки, раскладка, reload, проверка.
|
||||
|
||||
**Приёмка (живой домен):** `/sol/` и все его ассеты (`Build.loader.js`, `Build.wasm.unityweb`,
|
||||
`Build.data.unityweb`, `Build.framework.js.unityweb`, `TemplateData/*`) → **200**; `/` → 302 на `/29/`;
|
||||
`/27/`,`/28/`,`/29/` живы; `/api/state`,`/api/price`,`/solana-rpc/` → 200; `testfront` цел.
|
||||
|
||||
**⚠️ Что нашли в присланной сборке (отдать фронтендеру, не наш блокер):**
|
||||
1. **Двойной префикс API.** Игра зовёт `GET https://smart-updown.binom.pw/api/api/state` → **404**.
|
||||
Причина в исходниках: `SmartUpdownConfig.ApiBase()` для WebGL возвращает `Origin() + "/api"`,
|
||||
а `BackendClient.HttpGetAsync("/api/state")` добавляет путь целиком → `…/api` + `/api/state`.
|
||||
В сборке 29 тот же код, но она до этого места не доходила (в headless падала раньше).
|
||||
2. **`ReferenceError: getProvider is not defined`** — падение на `_SolanaBridgeConnect` при подключении
|
||||
кошелька. В собранном `framework.js` есть **вызовы** `getProvider()`, `errMsg()`, `loadWeb3()`,
|
||||
но **нет их определений** (в `SolanaBridge.jslib` они объявлены как функции внутри IIFE, а в
|
||||
библиотеку через `mergeInto(LibraryManager.library, {...})` экспортированы только
|
||||
`SolanaBridge*`-методы). Ошибка воспроизводится и в сборке 29 — то есть это **не регресс от
|
||||
переезда на nginx**, а дефект jslib-обвязки, который просто не был виден раньше.
|
||||
Файл — `Assets/Plugins/WebGL/SolanaBridge.jslib` (коммит `090ebf1`).
|
||||
|
||||
⚠️ Что **подтверждено** этим билдом: игра в браузере **стартует и доходит до игрового цикла**
|
||||
(`[BinaryRocket] Phase -> Idle` → `Phase -> Approach`), чего в headless-окружении не делала ни одна
|
||||
предыдущая сборка. То есть статика с nginx отдаётся корректно.
|
||||
|
||||
## ADR-013 — Разбор: редакторная сборка фронта vs наша Docker/CI (только исследование, 2026-09-15)
|
||||
|
||||
**Вопрос владельца:** почему сборка фронтендера (редактор Unity на Mac через TeamCity) «нормальная»,
|
||||
а наша (Docker/CI) «кривая косое». Задача была — исследовать, ничего не переделывать.
|
||||
|
||||
**Вердикт: Docker ни при чём.** Расхождение объясняется одной строкой в НАШЕМ скрипте сборки,
|
||||
которая насильно выключает сжатие.
|
||||
|
||||
| | наша CI (сборка 29) | эталон фронта (`/sol/`) |
|
||||
|---|---|---|
|
||||
| файлы | `Build/WebGL.{loader.js,wasm,data,framework.js}` | `Build/Build.*.unityweb` |
|
||||
| сжатие | **Disabled** (переопределено в `BuildScript.cs`) | **Brotli + Decompression Fallback** |
|
||||
| wasm | 52.2 МБ raw | 8.59 МБ raw (≈49.1 МБ распакованных) |
|
||||
| data | 18.3 МБ raw | 8.12 МБ raw (≈17.7 МБ распакованных) |
|
||||
| едет в браузер | **26 МБ** (nginx жмёт gzip на лету) | **16.4 МБ** |
|
||||
| версия Unity | 6000.3.16f1 | 6000.3.16f1 (та же!) |
|
||||
|
||||
**Причина.** `Assets/Editor/BuildScript.cs` (коммит `4468014`):
|
||||
`PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Disabled;` — комментарий «Без предсжатия:
|
||||
nginx отдаёт gzip по Accept-Encoding, мобильные прокси не ломают». Скрипт **затирает**
|
||||
`ProjectSettings.asset` (`webGLCompressionFormat: 0`). gzip жмёт 52-МБ wasm всего в ~3.3×; brotli тот же
|
||||
wasm жмёт в ~6×. Отсюда «у него нормально, у нас криво» — это транспорт, не сборка.
|
||||
|
||||
**Доки Unity:** *«If you enable Decompression Fallback, Unity appends the extension `.unityweb` to the
|
||||
build file names. Otherwise, Unity appends `.gz` / `.br`.»* То есть `.unityweb` — следствие
|
||||
Brotli+fallback, а не другой сборочный конвейер.
|
||||
|
||||
**Код в обеих сборках эквивалентен.** В распакованных `wasm` маркеры совпадают 1:1
|
||||
(`SolanaBridge`=5, `PriceFeed`=2, `UnityWebRequest`=44, `Tonemapping`=4, `PostProcess`=1). Разница
|
||||
размеров — сжатие/отладочные символы, не исходники.
|
||||
|
||||
**Наш конвейер — стандартная практика.** Образ `unityci/editor:ubuntu-6000.3.16f1-webgl-3.2.2` (GameCI),
|
||||
запуск `unity-editor -batchmode -nographics -quit -executeMethod BuildScript.BuildWebGL` + лицензия `.ulf`
|
||||
из env — ровно то, что описано в доках game.ci. «Своих кривых практик» здесь нет.
|
||||
|
||||
**⚠️ Поправка к прошлому отчёту (ADR-012):** оба дефекта, которые я назвал «дефектами сборки
|
||||
фронтендера», присутствуют и в НАШЕЙ сборке 29 — проверено побайтово:
|
||||
1. двойной префикс `/api/api/state` → 404: `ApiBase()` = `Origin()+"/api"`, а `BackendClient` зовёт
|
||||
`HttpGetAsync("/api/state")`. Общий дефект исходников.
|
||||
2. `ReferenceError: getProvider is not defined`: в `framework.js` есть вызовы `getProvider()`/`errMsg()`/
|
||||
`loadWeb3()`, но нет определений — одинаковая картина в обеих сборках.
|
||||
Это делает п.1–2 задачей для кодовой правки, а не «пересборки».
|
||||
|
||||
**Гипотеза (НЕ факт, требует проверки на железе):** в headless наша 29-я падала на шейдерах, а его
|
||||
сборка доходила до игрового цикла (`Phase → Idle → Approach`). Возможная причина — `-nographics`
|
||||
влияет на shader stripping / URP-варианты. Проверка: собрать без `-nographics`, сравнить поведение и
|
||||
размеры. На живом железе разница может не проявиться вовсе.
|
||||
|
||||
**Наши побочные находки:** nginx сейчас жмёт `.unityweb` gzip'ом на лету — 0 выгоды (файл уже сжат
|
||||
brotli), только CPU. Для `.unityweb` gzip надо выключать.
|
||||
|
||||
**Возможные шаги (НЕ выполнены — ждём решения):** (1) убрать переопределение `compressionFormat` /
|
||||
поставить `Brotli` + fallback → `.unityweb` как у фронта, −40% трафика; (2) либо `gzip_static`,
|
||||
а не сжатие на лету; (3) `gzip off` для `.unityweb`.
|
||||
|
||||
**Методика сравнения сборок** (переиспользовать при следующем разборе): распаковать brotli
|
||||
(заголовок `UnityWeb Compressed Content`, смещение 0) → размеры raw/распакованных → `index.html`
|
||||
(`buildUrl`, `dataUrl`,`codeUrl`,`frameworkUrl`) → число `function`-объявлений и наличие тел хелперов →
|
||||
версия Unity в `wasm` → заголовки (`Content-Encoding`,`cache-control`) → поведение в браузере.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* ЖИВАЯ приёмка API v2 шлюза smart-updown (ставка без суммы, 30% от баланса).
|
||||
*
|
||||
* BASE=https://smart-updown.binom.pw/api node gateway-api-v2-check.cjs
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. GET /state — все СТАРЫЕ поля на месте (обратная совместимость)
|
||||
* 2. GET /state — новые поля: bettor, betPercent, canBet, canBetReasonCode,
|
||||
* canBetReason, betAmountUnits, balance, openBet, lastBet
|
||||
* 3. /state — openBet и lastBet взаимоисключающие; у ставки есть status
|
||||
* 4. /state — можно ли ставить (canBet) и что баланс читается
|
||||
* 5. POST /bet {side:"up"} — БЕЗ суммы: accepted=true, есть betId и txSig
|
||||
* 6. POST /bet — retryAfterSeconds это ЧИСЛО (дельта), 0 < r <= expiry
|
||||
* 7. GET /state сразу после — openBet не null, status="open"
|
||||
* 8. POST /bet повторно — accepted=false, reasonCode=OPEN_BET, retryAfterSeconds > 0
|
||||
* 9. POST /bet {side:"кривой"} — 400
|
||||
* 10. ждать экспирацию ~20 с — /state: openBet=null, lastBet.status="closed"
|
||||
*/
|
||||
const BASE = process.env.BASE || "https://smart-updown.binom.pw/api";
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? "OK " : "FAIL"} | ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
const j = async (path, init) => {
|
||||
const r = await fetch(BASE + path, init);
|
||||
let body = null;
|
||||
try { body = await r.json(); } catch { /* не JSON */ }
|
||||
return { status: r.status, body };
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Поля, которые читает тестовый фронт и которые НЕЛЬЗЯ терять.
|
||||
const LEGACY_STATE = [
|
||||
"counter", "nextBetId", "minAmount", "maxAmount", "minAmountHuman",
|
||||
"maxAmountHuman", "multiplierBps", "expirySeconds", "paused", "admin",
|
||||
"relayer", "vaultBalance", "vaultBalanceHuman",
|
||||
];
|
||||
const NEW_STATE = [
|
||||
"bettor", "betPercent", "betAmountUnits", "betAmountHuman",
|
||||
"canBet", "canBetReasonCode", "canBetReason", "balance",
|
||||
"openBet", "lastBet",
|
||||
];
|
||||
|
||||
(async () => {
|
||||
console.log(`BASE=${BASE}\n`);
|
||||
|
||||
// --- 1-2. /state: старые и новые поля ---
|
||||
const st = await j("/state");
|
||||
const b = st.body || {};
|
||||
const missingLegacy = LEGACY_STATE.filter((k) => !(k in b));
|
||||
check("GET /state → 200", st.status === 200, `status=${st.status}`);
|
||||
check("нет потери старых полей", missingLegacy.length === 0,
|
||||
missingLegacy.length ? "нет: " + missingLegacy.join(",") : `${LEGACY_STATE.length} полей на месте`);
|
||||
const missingNew = NEW_STATE.filter((k) => !(k in b));
|
||||
check("новые поля присутствуют", missingNew.length === 0,
|
||||
missingNew.length ? "нет: " + missingNew.join(",") : `${NEW_STATE.length} полей на месте`);
|
||||
|
||||
console.log(` bettor=${b.bettor} betPercent=${b.betPercent}`);
|
||||
console.log(` canBet=${b.canBet} code=${b.canBetReasonCode} reason=${b.canBetReason}`);
|
||||
console.log(` balance=${JSON.stringify(b.balance)}`);
|
||||
console.log(` betAmountUnits=${b.betAmountUnits} (${b.betAmountHuman})`);
|
||||
|
||||
// --- 3. взаимоисключение openBet / lastBet + поле status ---
|
||||
const openBet = b.openBet || null;
|
||||
const lastBet = b.lastBet || null;
|
||||
check("openBet и lastBet не оба заполнены", !(openBet && lastBet),
|
||||
`openBet=${openBet ? openBet.id : "null"} lastBet=${lastBet ? lastBet.id : "null"}`);
|
||||
const shown = openBet || lastBet;
|
||||
if (shown) {
|
||||
check("у ставки есть поле status (open|closed)",
|
||||
shown.status === "open" || shown.status === "closed", `status=${shown.status}`);
|
||||
check("у ставки есть side/amountUnits/entryPrice",
|
||||
!!shown.side && !!shown.amountUnits && shown.entryPrice !== undefined,
|
||||
`side=${shown.side} amount=${shown.amountUnits} entry=${shown.entryPrice}`);
|
||||
} else {
|
||||
console.log(" (ставок ещё нет — пропускаем проверки формы)");
|
||||
}
|
||||
|
||||
// --- 4. можно ли ставить ---
|
||||
if (b.canBet !== true) {
|
||||
check("canBet=true (готовы ставить)", false, `code=${b.canBetReasonCode} reason=${b.canBetReason}`);
|
||||
console.log("\nдальше невозможно: шлюз отказывает. Причина выше.");
|
||||
process.exit(1);
|
||||
}
|
||||
check("canBet=true (готовы ставить)", true, `размер=${b.betAmountUnits}`);
|
||||
|
||||
// --- 5. ставка БЕЗ суммы ---
|
||||
const bet1 = await j("/bet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ side: "up" }),
|
||||
});
|
||||
const r1 = bet1.body || {};
|
||||
console.log(` ответ /bet: ${JSON.stringify(r1).slice(0, 300)}`);
|
||||
check("POST /bet {side} → 200", bet1.status === 200, `status=${bet1.status}`);
|
||||
check("ставка принята (accepted=true)", r1.accepted === true,
|
||||
`accepted=${r1.accepted} code=${r1.reasonCode} reason=${r1.reason}`);
|
||||
const betId = r1.betId || r1.id;
|
||||
check("есть betId (п.6)", !!betId, `betId=${betId}`);
|
||||
check("есть txSig (п.6)", typeof r1.txSig === "string" && r1.txSig.length > 20,
|
||||
`txSig=${r1.txSig ? r1.txSig.slice(0, 24) + "…" : r1.txSig}`);
|
||||
|
||||
// --- 6. retryAfterSeconds — ДЕЛЬТА ---
|
||||
const retry = r1.retryAfterSeconds;
|
||||
const expiry = Number(b.expirySeconds || 15);
|
||||
check("retryAfterSeconds — число-дельта (п.5)",
|
||||
typeof retry === "number" && retry > 0 && retry <= expiry,
|
||||
`retryAfterSeconds=${retry} (expiry=${expiry})`);
|
||||
|
||||
// --- 7. /state сразу после: открытая ставка ---
|
||||
await sleep(1500);
|
||||
const st2 = (await j("/state")).body || {};
|
||||
const ob = st2.openBet;
|
||||
check("openBet заполнен после ставки (п.7)", !!ob, `openBet=${ob ? ob.id : "null"}`);
|
||||
if (ob) {
|
||||
check("openBet.status = \"open\"", ob.status === "open", `status=${ob.status}`);
|
||||
check("openBet.id совпадает с ответом /bet", String(ob.id) === String(betId),
|
||||
`openBet.id=${ob.id} betId=${betId}`);
|
||||
check("openBet.retryAfterSeconds > 0",
|
||||
typeof ob.retryAfterSeconds === "number" && ob.retryAfterSeconds >= 0,
|
||||
`retryAfterSeconds=${ob.retryAfterSeconds}`);
|
||||
}
|
||||
|
||||
// --- 8. вторая ставка при открытой: отказ OPEN_BET ---
|
||||
const bet2 = await j("/bet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ side: "down" }),
|
||||
});
|
||||
const r2 = bet2.body || {};
|
||||
check("вторая ставка отклонена (accepted=false)", r2.accepted === false,
|
||||
`accepted=${r2.accepted}`);
|
||||
check("reasonCode=OPEN_BET", r2.reasonCode === "OPEN_BET", `code=${r2.reasonCode}`);
|
||||
check("есть reason по-русски (п.2)",
|
||||
typeof r2.reason === "string" && r2.reason.length > 5, `reason=${r2.reason}`);
|
||||
check("retryAfterSeconds > 0 при отказе по открытой (п.5)",
|
||||
typeof r2.retryAfterSeconds === "number" && r2.retryAfterSeconds > 0,
|
||||
`retryAfterSeconds=${r2.retryAfterSeconds}`);
|
||||
|
||||
// --- 9. кривой side ---
|
||||
const bet3 = await j("/bet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ side: "вбок" }),
|
||||
});
|
||||
check("кривой side → 400", bet3.status === 400, `status=${bet3.status}`);
|
||||
|
||||
// --- 10. дождаться авто-закрытия ---
|
||||
console.log("\nждём авто-закрытие (~22 с)…");
|
||||
await sleep(22000);
|
||||
const st3 = (await j("/state")).body || {};
|
||||
check("после экспирации openBet = null", st3.openBet === null,
|
||||
`openBet=${st3.openBet ? st3.openBet.id : "null"}`);
|
||||
const lb = st3.lastBet;
|
||||
check("lastBet заполнен после закрытия (п.7)", !!lb, `lastBet=${lb ? lb.id : "null"}`);
|
||||
if (lb) {
|
||||
check("lastBet.status = \"closed\"", lb.status === "closed", `status=${lb.status}`);
|
||||
check("lastBet.result заполнен", !!lb.result,
|
||||
`result=${lb.result} statusCode=${lb.statusCode}`);
|
||||
}
|
||||
check("canBet снова true после расчёта", st3.canBet === true,
|
||||
`canBet=${st3.canBet} code=${st3.canBetReasonCode}`);
|
||||
|
||||
// --- итог ---
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n=== ИТОГ: ${results.length - failed.length}/${results.length} пройдено ===`);
|
||||
if (failed.length) {
|
||||
console.log("ПРОВАЛЫ:");
|
||||
for (const f of failed) console.log(` - ${f.name} :: ${f.detail || ""}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("все проверки пройдены");
|
||||
})().catch((e) => {
|
||||
console.error("исключение:", e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
# nginx на Proxmox — раздача статики игры (LXC 152)
|
||||
|
||||
Боевой веб-слой игры «Бинарная ракета»: **корень `smart-updown.binom.pw` раздаётся nginx'ом
|
||||
на LXC 152 с версионированием по каталогам**. Бэк (API/WS) остаётся в кубе — см. ADR-011.
|
||||
|
||||
## Что где живёт
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| LXC | **152 `game-web`**, 192.168.76.152 (Server3, `pct exec 152` через `root@192.168.76.66`) |
|
||||
| nginx-конфиг | `/etc/nginx/sites-available/game` (симлинк в `sites-enabled/`) |
|
||||
| Сборки | `/opt/game/releases/<N>/` — `Build/`, `TemplateData/`, `index.html`, `ServiceWorker.js` |
|
||||
| Активная сборка | `/opt/game/current` → симлинк на `releases/<N>` |
|
||||
| Инструменты | `/opt/game/tools/` (`unpack-release.sh`, `publish-release.sh`) |
|
||||
| Логи | `/var/log/nginx/game-access.log`, `game-error.log` |
|
||||
|
||||
## Адреса
|
||||
|
||||
- `https://smart-updown.binom.pw/` — активная сборка;
|
||||
- `https://smart-updown.binom.pw/<N>/` — конкретная сборка (**обязательно со слэшем**);
|
||||
- `https://smart-updown.binom.pw/<N>/Build/WebGL.data` — ассет конкретной сборки;
|
||||
- `/api`, `/ws` — **не nginx**: их забирает внешний Traefik (VDS) → куб (ingress `smart-updown-api`).
|
||||
|
||||
## Публикация сборки
|
||||
|
||||
```bash
|
||||
# CI fun-game-front2 пушит образ images.binom.pw/fun/game-front2:<N>
|
||||
# затем на хосте Proxmox:
|
||||
ssh root@192.168.76.66 "pct exec 152 -- /opt/game/tools/publish-release.sh <N> --activate"
|
||||
```
|
||||
|
||||
Без `--activate` сборка ляжет в `/opt/game/releases/<N>/` и будет доступна по `/<N>/`,
|
||||
но корень останется прежним. Откат = `publish-release.sh <старый N> --activate`.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
curl -sI https://smart-updown.binom.pw/ | grep -iE 'server|last-modified' # nginx/1.24.0 (Ubuntu)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://smart-updown.binom.pw/<N>/
|
||||
curl -s https://smart-updown.binom.pw/api/state | head -c 80 # JSON от шлюза в кубе
|
||||
# сквозная живая приёмка (ставит реальную ставку):
|
||||
cd ~/WORK/games/pump-game-ops && BASE=https://smart-updown.binom.pw/api \
|
||||
WS_URL=wss://smart-updown.binom.pw/api/ws node tools/autoclose-check.cjs # ждём 9/9
|
||||
```
|
||||
|
||||
## Питфоллы
|
||||
|
||||
- **Слэш в конце обязателен** для версии: `/28` → 301 (редирект на `/28/`). Unity грузит ассеты
|
||||
относительным путём (`Build/WebGL.loader.js`), поэтому без слэша `Build/...` уедет в корень → 404.
|
||||
- **`skopeo ... dir:` пишет OCI-манифест**, где `layers` — словари. `unpack-release.sh` берёт
|
||||
`digest` каждого слоя и распаковывает по имени файла `<hex>` (не `sha256:...`).
|
||||
- **Корень — 302 на активную сборку** (`location = / { return 302 /<N>/; }` в
|
||||
`/etc/nginx/game-active.inc`, пишет `publish-release.sh --activate`). Нужен потому, что
|
||||
`index.html` Unity зовёт ассеты **относительным** путём (`buildUrl = "Build"`): без редиректа они
|
||||
запрашивались бы как `/Build/WebGL.wasm` — без номера сборки, а такой URL между релизами НЕ
|
||||
меняется → `immutable` заставлял браузер неделю отдавать старый билд. С редиректом путь становится
|
||||
`/29/Build/…` и кэш корректен.
|
||||
- **`immutable` только для путей с номером сборки** (`~^/[0-9]+/…`). Всё остальное, включая
|
||||
неверсионированные ассеты, — `no-store` (страховка от отравления кэша).
|
||||
- ⚠️ Если правишь `game.nginx.conf` — не забудь `include /etc/nginx/game-active.inc;`, иначе корень
|
||||
снова начнёт раздавать файлы напрямую и мина вернётся.
|
||||
- **Сборки копятся** (~68 МБ на версию) — ретеншен пока ручной, см. `TASKS.md`.
|
||||
- **Откат схемы целиком**: вернуть `/opt/traefik/static/smart-updown.yaml` из
|
||||
`/root/bak-smart-updown-nginx-2026-09-15/` на VDS — домен снова пойдёт целиком в куб.
|
||||
|
||||
## ⚠️ Приёмка визуально: ошибки шейдеров в headless-браузере — НЕ наш дефект
|
||||
|
||||
Проверка игры живым браузером (Browserbase/headless, без GPU) показывает:
|
||||
прогресс замирает на 90%, в консоли `ERROR: Shader Hidden/CoreSRP/CoreCopy shader is not
|
||||
supported on this GPU` и Unity-исключение из wasm. **Это артефакт headless-окружения без
|
||||
аппаратного GPU.**
|
||||
|
||||
Доказательство (контрольный опыт 15.09.2026): тот же образ `game-front2:28` открыт через **старый
|
||||
путь** (`kubectl port-forward` на под в кубе, `http://127.0.0.1:18080/`) — **ровно те же** ошибки
|
||||
шейдеров, ровно те же 90%, те же 11 ресурсов. Значит миграция на nginx тут ни при чём.
|
||||
|
||||
Что реально проверяемо в headless: заголовки, коды, содержимое, инициализация движка
|
||||
(`Creating WebGL 2.0 context`, `Initialize engine version: 6000.3.16f1`), факт загрузки **всех**
|
||||
ассетов (`/Build/WebGL.wasm` 52 234 795 б, `.data` 18 437 188 б, `.framework.js` 489 223 б — по 200).
|
||||
Отрисовку сцены проверять только на реальном железе с GPU (принятие на железе — за владельцем).
|
||||
|
||||
⚠️ `publish-release.sh` содержит креды реестра (`subochev:…`) в открытом виде — файл живёт только
|
||||
внутри LXC 152 как root (`/opt/game/tools/`), в git лежит шаблон из репы ops. Не тиражировать наружу.
|
||||
|
||||
## Именованные сборки (zip от фронтендера) — рядом с номерными
|
||||
|
||||
Кроме номерных релизов из CI (`/opt/game/releases/<N>/`) на nginx можно положить **именованную**
|
||||
сборку — готовый zip от фронтендера, вне релизного цикла:
|
||||
|
||||
```bash
|
||||
# zip сначала доставить на LXC 152 (через хост проксмокса):
|
||||
scp build.zip root@192.168.76.66:/tmp/build.zip
|
||||
ssh root@192.168.76.66 "pct push 152 /tmp/build.zip /tmp/build.zip"
|
||||
# разложить под именем:
|
||||
ssh root@192.168.76.66 "pct exec 152 -- /opt/game/tools/publish-zip.sh sol /tmp/build.zip"
|
||||
```
|
||||
|
||||
Адрес: **`https://smart-updown.binom.pw/sol/`**. Активную сборку такая публикация **не трогает** —
|
||||
корень по-прежнему редиректит на текущий номер.
|
||||
|
||||
**Питфоллы именованных сборок:**
|
||||
- Служебные location обязаны иметь **`^~`** (`^~ /api`, `^~ /ws`, `^~ /solana-rpc/`). Regex-локейшн
|
||||
сборок `^/(?<build>[a-zA-Z0-9][a-zA-Z0-9_-]*)…` иначе перехватит `/api/...` как «сборку с именем
|
||||
api» — regex в nginx приоритетнее обычного префикса. Без `^~` API ломается (проверено).
|
||||
- `immutable` — **только для номерных** каталогов (`~^/[0-9]+/…`). Именованную папку могут перезалить
|
||||
тем же именем → её ассеты кэшировать нельзя.
|
||||
- Сборки фронтендера бывают на `.unityweb`-ассетах (`Build.wasm.unityweb` и т.п.) — расширение
|
||||
`unityweb` в маске кэша учтено.
|
||||
- Имя папки не должно совпадать со служебным путём (`api`, `ws`, `solana-rpc`) — скрипт это проверяет.
|
||||
@@ -0,0 +1,109 @@
|
||||
# nginx для раздачи Unity-сборок «Бинарной ракеты» — LXC 152 (game-web, 192.168.76.152)
|
||||
# Файл: /etc/nginx/sites-available/game
|
||||
#
|
||||
# Модель версионирования (два вида сборок, живут рядом):
|
||||
# /opt/game/releases/<N>/ — номерные релизы (1, 2, 3, … 29) из CI/реестра
|
||||
# /opt/game/releases/<имя>/ — именованные сборки (например `sol`), присланные zip'ом
|
||||
# /opt/game/current -> releases/<N> — активная сборка (симлинк, переключает publish-release.sh)
|
||||
#
|
||||
# https://smart-updown.binom.pw/ -> 302 на активную сборку (/29/)
|
||||
# https://smart-updown.binom.pw/29/ -> номерная сборка 29
|
||||
# https://smart-updown.binom.pw/sol/ -> именованная сборка `sol`
|
||||
# https://smart-updown.binom.pw/29/Build/WebGL.data -> ассет сборки 29
|
||||
#
|
||||
# ⚠️ ПОЧЕМУ КОРЕНЬ РЕДИРЕКТИТ, А НЕ ОТДАЁТ ФАЙЛЫ:
|
||||
# index.html сборки ссылается на ассеты ОТНОСИТЕЛЬНЫМ путём (`buildUrl = "Build"`).
|
||||
# Если отдавать его прямо с корня, ассеты запрашиваются как /Build/WebGL.wasm — БЕЗ номера
|
||||
# сборки. Такой URL между релизами НЕ меняется, а мы отдаём его `immutable` на 7 суток →
|
||||
# браузер игрока неделю держит ассеты старой сборки и игнорирует новый билд (мина из ADR-010).
|
||||
# При редиректе на /<N>/ все ассеты получают версионированный URL (/29/Build/...), старый URL
|
||||
# просто перестаёт использоваться, и `immutable` становится корректным.
|
||||
# Номер активной сборки пишет publish-release.sh в отдельный include-файл /etc/nginx/game-active.inc.
|
||||
#
|
||||
# ⚠️ ПОРЯДОК LOCATION. Служебные пути (/api, /ws, /solana-rpc) объявлены с префиксом `^~`,
|
||||
# потому что regex-локейшн сборок `^/(?<build>…)` иначе перехватил бы их (regex в nginx
|
||||
# приоритетнее обычного префикса). `^~` подавляет проверку regex для этого префикса.
|
||||
#
|
||||
# API и WebSocket nginx НЕ обслуживает: их забирает Traefik на VDS
|
||||
# (router smart-updown-api: PathPrefix /api,/ws -> куб 192.168.76.120).
|
||||
# Локальные location /api, /ws, /solana-rpc — страховка для запросов,
|
||||
# которые всё же дойдут до nginx (прямой заход по IP, старые сборки).
|
||||
|
||||
# Ассеты Unity внутри НОМЕРНОГО каталога неизменяемы -> immutable.
|
||||
# Всё остальное (включая именованные сборки: их папку могут перезалить) -> no-store.
|
||||
map $uri $game_cache_control {
|
||||
default "no-store";
|
||||
~^/[0-9]+/.+\.(js|wasm|data|mem|symbols|css|png|jpg|jpeg|gif|svg|woff2?|unityweb)$ "public, max-age=604800, immutable";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /opt/game/current;
|
||||
index index.html;
|
||||
|
||||
access_log /var/log/nginx/game-access.log;
|
||||
error_log /var/log/nginx/game-error.log warn;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 512;
|
||||
gzip_types application/javascript application/wasm application/json text/css application/octet-stream;
|
||||
|
||||
add_header Cache-Control $game_cache_control;
|
||||
|
||||
# --- корень: 302 на активную сборку ---------------------------------------
|
||||
# Файл переписывает publish-release.sh при --activate.
|
||||
include /etc/nginx/game-active.inc;
|
||||
|
||||
# --- страховка: API (^~ — приоритетнее regex-локейшна сборок) -------------
|
||||
# Нормальный /api идёт мимо nginx (Traefik VDS -> куб). Если запрос всё же
|
||||
# пришёл сюда — уходим в куб на k3s-Traefik с правильным Host, дальше
|
||||
# ingress smart-updown-api ведёт в шлюз :8895.
|
||||
location ^~ /api {
|
||||
proxy_pass http://192.168.76.120;
|
||||
proxy_set_header Host smart-updown.binom.pw;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# --- страховка: WebSocket -------------------------------------------------
|
||||
location ^~ /ws {
|
||||
proxy_pass http://192.168.76.120;
|
||||
proxy_set_header Host smart-updown.binom.pw;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# --- страховка: Solana RPC -------------------------------------------------
|
||||
# /solana-rpc/ НЕ ловится PathPrefix /api на VDS, поэтому уходит в корневой
|
||||
# router, т.е. сюда. Старые сборки игры стучатся именно в этот путь.
|
||||
location ^~ /solana-rpc/ {
|
||||
proxy_pass http://192.168.76.181:8899/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# --- сборки: /<N>/... и /<имя>/... ---------------------------------------
|
||||
# /29/index.html -> /opt/game/releases/29/index.html
|
||||
# /sol/index.html -> /opt/game/releases/sol/index.html
|
||||
# /29/Build/WebGL.data -> /opt/game/releases/29/Build/WebGL.data
|
||||
location ~ ^/(?<build>[a-zA-Z0-9][a-zA-Z0-9_-]*)(?<subpath>/.*)?$ {
|
||||
root /opt/game/releases;
|
||||
try_files /$build$subpath /$build$subpath/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Публикация сборки игры на nginx (LXC 152, game-web).
|
||||
#
|
||||
# publish-release.sh <тег> [--activate]
|
||||
#
|
||||
# Что делает:
|
||||
# 1. тянет образ images.binom.pw/fun/game-front2:<тег> из реестра (skopeo copy в dir);
|
||||
# 2. распаковывает статику в /opt/game/releases/<тег>/;
|
||||
# 3. без --activate оставляет активной текущую сборку (корень не меняется);
|
||||
# с --activate переключает симлинк /opt/game/current на новую версию
|
||||
# И пишет /etc/nginx/game-active.inc (редирект корня на /<тег>/);
|
||||
# 4. печатает проверку.
|
||||
#
|
||||
# Итог: https://smart-updown.binom.pw/ -> 302 на активную сборку
|
||||
# https://smart-updown.binom.pw/<тег>/ -> конкретная сборка
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?использование: publish-release.sh <тег> [--activate]}"
|
||||
ACTIVATE="${2:-}"
|
||||
REG="images.binom.pw/fun/game-front2"
|
||||
CREDS="subochev:Drovosek319"
|
||||
IMG="/tmp/gameimg-${TAG}"
|
||||
ACTIVE_INC="/etc/nginx/game-active.inc"
|
||||
|
||||
case "$TAG" in
|
||||
''|*[!0-9]*) echo "тег должен быть числом (номер сборки), получено: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "=== 1/4 тяну образ ${REG}:${TAG} ==="
|
||||
rm -rf "$IMG"
|
||||
skopeo copy --src-creds "$CREDS" "docker://${REG}:${TAG}" "dir:${IMG}"
|
||||
# креды не нужны при распаковке
|
||||
chmod -R a+rX "$IMG"
|
||||
|
||||
echo "=== 2/4 распаковываю статику в /opt/game/releases/${TAG} ==="
|
||||
/opt/game/tools/unpack-release.sh "$TAG" "$IMG"
|
||||
|
||||
if [ "$ACTIVATE" = "--activate" ]; then
|
||||
echo "=== 3/4 переключаю активную сборку на ${TAG} ==="
|
||||
PREV="$(readlink -f /opt/game/current || echo none)"
|
||||
rm -f /opt/game/current
|
||||
ln -s "/opt/game/releases/${TAG}" /opt/game/current
|
||||
|
||||
# корень редиректим на /<тег>/: тогда ассеты получают версионированный URL
|
||||
# и immutable-кэш перестаёт «прилипать» между релизами (мина из ADR-010).
|
||||
cat > "$ACTIVE_INC" <<EOF
|
||||
# АВТОГЕНЕРАЦИЯ — пишет publish-release.sh при --activate. Не править руками.
|
||||
location = / {
|
||||
return 302 /${TAG}/;
|
||||
}
|
||||
location = /index.html {
|
||||
return 302 /${TAG}/;
|
||||
}
|
||||
EOF
|
||||
echo "было: $PREV"
|
||||
echo "стало: $(readlink -f /opt/game/current)"
|
||||
echo "корень -> /${TAG}/ (${ACTIVE_INC})"
|
||||
else
|
||||
echo "=== 3/4 активную сборку НЕ трогаю (нет --activate) ==="
|
||||
echo "активна: $(readlink -f /opt/game/current)"
|
||||
fi
|
||||
|
||||
echo "=== 4/4 проверка ==="
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
for u in "/" "/${TAG}/" "/${TAG}/index.html" "/${TAG}/Build/WebGL.framework.js"; do
|
||||
printf ' %-42s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${u}")"
|
||||
done
|
||||
echo "готово: сборка ${TAG}"
|
||||
rm -rf "$IMG"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Публикация готового zip-билда (например, присланного фронтендером) в ОТДЕЛЬНУЮ папку nginx.
|
||||
#
|
||||
# publish-zip.sh <имя-папки> <путь-к-zip>
|
||||
#
|
||||
# Имя папки может быть НЕ числовым (например `sol`) — такие сборки живут рядом с
|
||||
# номерными релизами (/opt/game/releases/<N>/) и АКТИВНУЮ сборку НЕ трогают:
|
||||
# корень продолжает редиректить на текущий номер, а эта сборка доступна по /<имя>/.
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:?использование: publish-zip.sh <имя-папки> <путь-к-zip>}"
|
||||
ZIP="${2:?использование: publish-zip.sh <имя-папки> <путь-к-zip>}"
|
||||
DEST="/opt/game/releases/${NAME}"
|
||||
TMP="/tmp/unzip-${NAME}"
|
||||
|
||||
case "$NAME" in
|
||||
*[!a-zA-Z0-9_-]*|'') echo "имя папки: только буквы/цифры/дефис/подчёркивание, получено: $NAME" >&2; exit 1 ;;
|
||||
api|ws|solana-rpc) echo "имя '$NAME' занято служебным location — выбери другое" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
[ -f "$ZIP" ] || { echo "нет файла $ZIP" >&2; exit 1; }
|
||||
|
||||
command -v unzip >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unzip >/dev/null
|
||||
|
||||
echo "=== 1/3 распаковываю ${ZIP} ==="
|
||||
rm -rf "$TMP"; mkdir -p "$TMP"
|
||||
unzip -q "$ZIP" -d "$TMP"
|
||||
|
||||
# если в архиве одна вложенная папка — поднимаем содержимое наверх
|
||||
CNT=$(find "$TMP" -mindepth 1 -maxdepth 1 | wc -l)
|
||||
if [ "$CNT" = 1 ]; then
|
||||
INNER="$(find "$TMP" -mindepth 1 -maxdepth 1)"
|
||||
if [ -d "$INNER" ]; then
|
||||
mv "$INNER"/* "$TMP"/ 2>/dev/null || true
|
||||
rmdir "$INNER" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== 2/3 раскладываю в ${DEST} ==="
|
||||
rm -rf "$DEST"; mkdir -p "$DEST"
|
||||
cp -a "$TMP"/. "$DEST"/
|
||||
chmod -R a+rX /opt/game/releases
|
||||
rm -rf "$TMP"
|
||||
|
||||
[ -f "$DEST/index.html" ] || { echo "ВНИМАНИЕ: в сборке нет index.html — проверь архив" >&2; exit 1; }
|
||||
|
||||
echo "=== 3/3 проверка ==="
|
||||
nginx -t >/dev/null && systemctl reload nginx
|
||||
for u in "/${NAME}/" "/${NAME}/index.html"; do
|
||||
printf ' %-46s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${u}")"
|
||||
done
|
||||
echo "готово: https://smart-updown.binom.pw/${NAME}/"
|
||||
du -sh "$DEST"
|
||||
ls -la "$DEST"
|
||||
@@ -0,0 +1,62 @@
|
||||
http:
|
||||
# ── middlewares ─────────────────────────────────────────────────────────
|
||||
middlewares:
|
||||
# Подмена Host на входе в куб — k3s-Traefik роутит именно по Host,
|
||||
# а ingress `smart-updown-api` объявлен на smart-updown.binom.pw.
|
||||
smart-updown-host:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
Host: "smart-updown.binom.pw"
|
||||
smart-updown-redirect-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
|
||||
# ── routers ─────────────────────────────────────────────────────────────
|
||||
routers:
|
||||
# API и WebSocket игры: уходят в k3s (192.168.76.120) на ingress
|
||||
# `smart-updown-api` (ns game), дальше в TS-шлюз :8895.
|
||||
# Отдельный router потому, что PathPrefix специфичнее Host:
|
||||
# так /api и /ws не перехватываются статикой nginx.
|
||||
smart-updown-api-router:
|
||||
rule: "Host(`smart-updown.binom.pw`) && (PathPrefix(`/api`) || PathPrefix(`/ws`))"
|
||||
service: smart-updown-api
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: mycloudflare
|
||||
|
||||
# Корень: статика игры. Раздаёт nginx на LXC 152 (game-web, 192.168.76.152)
|
||||
# в домашней сети через wg-туннель. Версии — каталогами (/28/, /29/, ...),
|
||||
# корень — активная сборка (симлинк /opt/game/current).
|
||||
smart-updown-router:
|
||||
rule: Host(`smart-updown.binom.pw`)
|
||||
service: smart-updown
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: mycloudflare
|
||||
|
||||
# http -> https
|
||||
smart-updown-http-redirect:
|
||||
rule: Host(`smart-updown.binom.pw`)
|
||||
entryPoints:
|
||||
- web
|
||||
service: noop
|
||||
middlewares:
|
||||
- smart-updown-redirect-https
|
||||
|
||||
# ── services ────────────────────────────────────────────────────────────
|
||||
services:
|
||||
smart-updown-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.76.120"
|
||||
smart-updown:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.76.152"
|
||||
noop:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:9"
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Распаковка образа игры из dir-дампа skopeo в версионированный каталог nginx.
|
||||
# Использование: unpack-release.sh <тег> <dir-дамп образа>
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$1"
|
||||
IMG="$2"
|
||||
DEST="/opt/game/releases/${TAG}"
|
||||
|
||||
[ -d "$IMG" ] || { echo "нет дампа $IMG"; exit 1; }
|
||||
|
||||
rm -rf /tmp/rootfs-${TAG}
|
||||
mkdir -p /tmp/rootfs-${TAG}
|
||||
|
||||
cd "$IMG"
|
||||
# слои из manifest.json в правильном порядке
|
||||
/usr/bin/python3 - <<'PY' > /tmp/layers-${TAG}.txt
|
||||
import json
|
||||
m = json.load(open('manifest.json'))
|
||||
for l in m['layers']:
|
||||
print(l['digest'].split(':', 1)[1])
|
||||
PY
|
||||
|
||||
while read -r layer; do
|
||||
[ -z "$layer" ] && continue
|
||||
case "$layer" in
|
||||
*tar.gz|*tgz) tar -xzf "$layer" -C /tmp/rootfs-${TAG} ;;
|
||||
*tar.zst) tar --zstd -xf "$layer" -C /tmp/rootfs-${TAG} ;;
|
||||
*) tar -xf "$layer" -C /tmp/rootfs-${TAG} ;;
|
||||
esac
|
||||
done < /tmp/layers-${TAG}.txt
|
||||
|
||||
SRC="/tmp/rootfs-${TAG}/usr/share/nginx/html"
|
||||
[ -d "$SRC" ] || { echo "нет $SRC в образе"; ls /tmp/rootfs-${TAG}/usr/share/nginx 2>/dev/null; exit 1; }
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -a "$SRC/." "$DEST/"
|
||||
chmod -R a+rX /opt/game/releases
|
||||
rm -rf /tmp/rootfs-${TAG} /tmp/layers-${TAG}.txt
|
||||
|
||||
echo "=== релиз ${TAG} разложен в ${DEST} ==="
|
||||
ls -la "$DEST"
|
||||
du -sh "$DEST"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Ждём завершения последнего прогона CI fun-game-front2 и печатаем итог + УРЛ образа.
|
||||
AUTH="-u subochev:Drovosek319"
|
||||
BASE="https://git.binom.pw/api/v1"
|
||||
REPO="subochev/fun-game-front2"
|
||||
|
||||
for i in $(seq 1 120); do
|
||||
OUT=$(curl -s $AUTH "$BASE/repos/$REPO/actions/runs?limit=1")
|
||||
ST=$(echo "$OUT" | python3 -c "import sys,json;r=json.load(sys.stdin)['workflow_runs'][0];print(r['id'],r['run_number'],r['status'],r.get('conclusion'))")
|
||||
set -- $ST
|
||||
RUN_ID=$1; NUM=$2; STATUS=$3; CONC=$4
|
||||
echo "$(date +%H:%M:%S) run #$NUM статус=$STATUS вывод=$CONC"
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
echo "=== ИТОГ run #$NUM: $CONC ==="
|
||||
if [ "$CONC" != "success" ]; then
|
||||
echo "--- хвост лога провалившихся шагов ---"
|
||||
curl -s $AUTH "$BASE/repos/$REPO/actions/runs/$RUN_ID/jobs" | python3 -c "
|
||||
import sys,json
|
||||
for j in json.load(sys.stdin).get('jobs',[]):
|
||||
print('job:', j['name'], '|', j.get('conclusion'))
|
||||
for s in j.get('steps',[]):
|
||||
if s.get('conclusion') not in ('success','skipped',None):
|
||||
print(' FAILED step:', s['name'], s.get('conclusion'))
|
||||
"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "=== ТАЙМАУТ ожидания (60 мин) ==="
|
||||
exit 1
|
||||
Reference in New Issue
Block a user