Fix 2026-07-22: local .NET admin (C:\sites\stostayer\Web.config) S3 providers re-pointed to stostayer MinIO. 3 bugs in original config: wrong creds (AKIAJ2YJP72W6ZHCRE6Q = books-vds root, not stayer_minio), wrong endpoint (minio.stostayer.ru is not S3 API port; prod uses minio-api.stostayer.ru), wrong region (local -> AmazonS3Exception expecting us-west-1). Creds NOT in pass — live in stostayer.new config/default.json. Admin now shares prod storage; manual App_Data mirroring retired. + index.md, log.md. Also carries pending handoff (NEXT_SESSION) + runbook update from 2026-07-21 stostayer-web 0.3.20 deploy session. Co-Authored-By: Claude <noreply@anthropic.com>
121 lines
14 KiB
Markdown
121 lines
14 KiB
Markdown
---
|
||
title: stostayer-web — деплой легаси web на прод клиента (runbook)
|
||
status: live
|
||
tags: [stostayer, docker, portainer, deployment, nuxt2, esm, ops, rollback]
|
||
related: [[portainer-stack-management-vds]]
|
||
updated: 2026-06-17
|
||
---
|
||
|
||
# stostayer-web — деплой легаси web на прод клиента
|
||
|
||
Прод `https://www.stostayer.ru` = легаси `packages/web` (Nuxt 2, CJS, SSR) из монорепо `victor/stostayer.new`. web4 (Nuxt 4) ещё НЕ переключён. Хостится контейнером, управляется Portainer-стеком у клиента.
|
||
|
||
> **Машина клиента, настраивает их админ — мы только деплоим свой web-образ, конфиги хоста не трогаем.** Деталь доступов/инфры — в `stostayer.new/.wiki/concepts/client-infra-access.md`. Все креды — `pass stostayer/client`.
|
||
|
||
## Параметры
|
||
|
||
| Что | Значение |
|
||
|---|---|
|
||
| Хост | `91.222.236.225` (SSH `:20435`, юзер `victor` + sudo-с-паролем; парольный SSH) |
|
||
| Reverse-proxy | **Angie** (форк nginx) на `:443`, навешивает **Basic-auth** на сервисные сабдомены |
|
||
| Registry | `docker.stostayer.ru` (BA те же, что Angie BA) — образы `stostayer-web:<tag>` |
|
||
| Стек | Portainer **`stostayer-web` Id 16, EndpointId 3**, compose в Portainer-volume `/data/compose/16/docker-compose.yml` |
|
||
| Прод-тег (2026-06-17) | `0.3.18`. На хосте лежат старые теги `0.3.6…0.3.18` под откат |
|
||
| Образ | `packages/web/Dockerfile`: `node:16` → `yarn install` → `yarn build` (nuxt build) → `CMD yarn start`. pm2/`ecosystem.config.js` в проде НЕ используется |
|
||
|
||
## Канал деплоя (build здесь → registry → Portainer)
|
||
|
||
### 1. Build образа (локально, offline)
|
||
|
||
Тег образа независим от `packages/web/package.json` version — это чисто image-тег, инкремент от прода (`0.3.18` → `0.3.19`). Кода/версий не бампать.
|
||
|
||
```bash
|
||
# в чекауте нужного коммита, .yarn/cache populated
|
||
docker build -f packages/web/Dockerfile -t docker.stostayer.ru/stostayer-web:<tag> . # контекст = корень репо
|
||
```
|
||
|
||
Гочи build:
|
||
- **`.dockerignore` обязателен** (его нет в репо) — иначе `node_modules`/`.git` улетают в build-контекст. Создать временный: `node_modules **/node_modules .git apps docs build *.zip` и т.п. (на явные `COPY` не влияет).
|
||
- **Локальная `npmAuthToken: "${VERDACCIO_TOKEN}"` в `.yarnrc.yml`** (рабочая модификация, не в коммите) **ломает build**: yarn внутри образа падает `Usage Error: Environment variable not found (VERDACCIO_TOKEN)`. Убрать эту строку из build-копии (потом вернуть).
|
||
- **Offline против `.yarn/cache`:** добавить `enableNetwork: false` в `.yarnrc.yml` — depы пекутся из cache, verdaccio-токен в образе не нужен. (Cache содержит third-party зипы; их версии ESM-миграция не меняла, так что cache от master-tip обычно покрывает и более старые коммиты.)
|
||
|
||
### 2. Push в registry
|
||
|
||
```bash
|
||
echo '<ba_pass>' | docker login docker.stostayer.ru -u Victor --password-stdin
|
||
docker push docker.stostayer.ru/stostayer-web:<tag>
|
||
```
|
||
|
||
> ⚠️ **ОДИН push, без retry-циклов.** Инцидент 2026-06-17: retry-шторм (6 попыток, образ 3.29GB) на `docker.stostayer.ru` засветил egress нашего **VPN** → хостинг-провайдер забанил VPN-IP → ВЕСЬ HTTPS дом→хост (`:443`: сайт + registry + portainer) стал TLS-fail (`schannel: failed to receive handshake`), выглядело как «сайт лёг» (а TCP 443/SSH 20435 — открыты). Лечится сменой VPN. **На client-инфру: одна попытка, сбой → стоп и к человеку, не долбить.** (см. [[verify-on-real-client-not-own-curl-tests]])
|
||
>
|
||
> ⚠️ **Инцидент 2026-07-21: единичный large-push ТОЖЕ триггерит бан** (не только retry-storm). Первый push `0.3.20` (3.33GB) с VPN → тот же VPN-IP бан → `:443` к хосту TLS-fail с операторского IP (сайт для остальных посетителей работал — Angie access-log это подтвердил; SSH:20435 оставался открыт). **Бан НЕ IP-специфичный для всех — для остальных сайт жив, режется только egress-IP источника push'а.** Митигация: (a) **resume-push после смены egress** — `docker push` resumable, уже залитые слои «Layer already exists» (без re-upload), добивается только остаток + manifest → egress мал → повторный бан не триггерится; (b) **build-on-host + push в localhost-registry** (registry-контейнер на хосте, `127.0.0.1` — вообще без внешнего egress) — самый чистый путь для крупных образов. **Не пытаться долбить упавший push повторно с того же egress.**
|
||
|
||
### 3. Передеплой стека — с ХОСТА, мимо Angie BA
|
||
|
||
Portainer published **только в docker-сеть** (на host-localhost его НЕТ — там MinIO на `:9000`). Прямой Portainer-API из дома ломает Angie BA (Bearer затирает Basic → 401; auth-cookie не ставится). Решение — бить API по **IP контейнера portainer с самого хоста**:
|
||
|
||
```bash
|
||
# SSH (PuTTY plink; host-key уже известен)
|
||
plink -ssh -batch -hostkey SHA256:6Zk14J/UakVqBYm/fMPFrL3gosdBdyZOKZnbA0GVnD0 \
|
||
-P 20435 -pw '<ssh_pass>' victor@91.222.236.225 "bash -s" <<'EOF'
|
||
base=http://172.18.0.2:9000 # docker inspect portainer → его IP в сети bridge
|
||
JWT=$(curl -s $base/api/auth -H 'Content-Type: application/json' \
|
||
-d '{"username":"victor","password":"<app_pass>"}' | jq -r .jwt)
|
||
FILE=$(curl -s -H "Authorization: Bearer $JWT" $base/api/stacks/16/file | jq -r .StackFileContent)
|
||
NEW=$(printf '%s' "$FILE" | sed 's#stostayer-web:0.3.18#stostayer-web:<NEWTAG>#')
|
||
PAYLOAD=$(jq -n --arg c "$NEW" '{stackFileContent:$c, env:[], prune:false, pullImage:true}')
|
||
curl -s -w "\nHTTP=%{http_code}\n" -X PUT -H "Authorization: Bearer $JWT" \
|
||
-H "Content-Type: application/json" -d "$PAYLOAD" "$base/api/stacks/16?endpointId=3"
|
||
EOF
|
||
```
|
||
|
||
- `docker` на хосте — через `sudo` (victor не в группе docker): `echo '<ssh_pass>' | sudo -S <cmd>`. Sudo-пароль = SSH-пароль victor.
|
||
- `jq` на хосте есть. Скрипт через `bash -s`/stdin — чисто с кавычками; UTF-8 round-trip compose не портит (в отличие от PowerShell Invoke-RestMethod, [[portainer-stack-management-vds]] gotcha #9).
|
||
- `pullImage:true` → Portainer сам тянет новый тег из `docker.stostayer.ru` (хост-docker уже авторизован в registry).
|
||
|
||
### 4. Verify (read-only, без нагрузки на публичный сайт)
|
||
|
||
```bash
|
||
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' | grep stostayer-web # образ = новый тег, Up
|
||
docker logs --tail 20 stostayer-web # чистый старт Nuxt, без ошибок/краш-лупа
|
||
```
|
||
|
||
**Verify контента (напр. цен) — с хоста, прямым curl в nuxt-контейнер** (Angie даёт 403 на no-UA запрос с самого хоста; обход — `localhost:3000`, host-network):
|
||
```bash
|
||
# на хосте (sudo docker + host-network контейнер на :3000)
|
||
curl -s -H "Host: www.stostayer.ru" -A "Mozilla/5.0" http://localhost:3000/<page> -o /tmp/v.html
|
||
grep -c "<expected-text>" /tmp/v.html
|
||
```
|
||
**Гоча verify-URL:** bare `/remont-kondicionerov` 301-редиректит на `/remont/remont-kondicionerov` (DB-driven `oldPageRedirect`/unit-slug mapping) — это CMS-контент-страница БЕЗ nuxt-компонента. Компоненты `pages/<dir>/*.vue` реально live на своих nuxt-маршрутах (напр. `/remont-kondicionerov/{zapravka,diagnostika}-kondicionera`) — верифицируй по ним, не по bare-URL.
|
||
|
||
**Гоча локального smoke-контейнера:** `packages/web/config/default.json` содержит **прод-БД** (`www.stostayer.ru:3306` MariaDB + `:1433` MSSQL), и `www.stostayer.ru` резолвится в публичный IP хоста → smoke-контейнер без env-overrides **подключается к prod-БД клиента по интернету** (read-only SSR, без writes, но всё равно нежелательно). Для чистого локального smoke — переопределяй `data.sequelize.*.host` env'ом на localhost/заглушку, либо глуши контейнер сразу после `Server Listening`.
|
||
|
||
### Откат
|
||
|
||
Тот же PUT с прежним тегом (`<NEWTAG>` → `0.3.18`), `pullImage:true`. 0.3.18 на хосте есть. RTO ~30-60с (пересоздание контейнера).
|
||
|
||
## ✅ БЛОКЕР РАЗРЕШЁН 2026-07-21 (retrofit + snolla 0.7.6)
|
||
|
||
> **Legacy web снова собирается+запускается с master.** Решение: ретрофит `packages/web` (`require()`→dynamic `import()` в 5 местах, 2 serverMiddleware → CJS) + фикс `@snollajs/snolla@0.7.6` (bare-`get`-баг, см. ниже). Образ `0.3.20` собран, запушен, передеплоен на прод stostayer.ru, verify зелёный. Ретрофит лежит в working-tree stostayer.new (незакоммичен на 2026-07-21) — **stostayer.new должен закоммитить** его в master, иначе следующий legacy-web деплой упрётся в ту же стену. Секция ниже оставлена как история.
|
||
|
||
## ⛔ БЛОКЕР (история, 2026-06-17): легаси web НЕ пересобирается с текущего master (ESM-стена)
|
||
|
||
> На 2026-06-17 фикс формы жалоб (`120bc07`, задача `stostayer-web-complaint-form-deploy`) **выкатить не удалось** — два ESM-барьера:
|
||
|
||
1. **Build-time:** с master-tip `yarn build` (nuxt) падает `SyntaxError: await is only valid in async functions...` — `@stostayer/data` после таски `esm-data-dual` грузит модели через **top-level await**; цепочка `nuxt.config.js → @stostayer/api → @stostayer/data`, Nuxt2 читает конфиг через jiti → top-level await недопустим. Корень — коммит `c805e7e` (ESM-миграция data/api).
|
||
2. **Runtime:** cherry-pick `120bc07` на `d02f740` (родитель c805e7e, «последний CJS-собираемый master») **собирается**, но контейнер **краш-лупит**: `ERR_REQUIRE_ESM` — `packages/web/server/index.js:6` `require('@snollajs/snolla')`, а версия `@snollajs/snolla` из lockfile d02f740 уже **ESM**. Т.е. d02f740 не runtime-чистая.
|
||
|
||
Прод `0.3.18` жив только потому, что собран на ещё более старом стейте (CJS `@snollajs/snolla`, CJS data/api).
|
||
|
||
**Вердикт (stostayer.new подтвердил 2026-06-17):** легаси-web `require()`-ит **5** ESM-ставших пакетов — `@snollajs/snolla` (0.7.4, `server/index.js:6`), `@stostayer/api` (`nuxt.config.js:3`), `@snollajs/content-api` (0.8.0, `nuxt.config.js:4`), `@stostayer/data` (`serverMiddleware/oldPagesRedirections.js:14` + `redirections.js:14`). Чтобы найти base где ВСЕ пять ещё CJS — надо к ~0.3.18 и потерять всё с тех пор. **Чейз базы бесполезен. `packages/web` (node16/CJS/Nuxt2) не пересобираем ни с какого свежего дерева — весь dep-граф pure-ESM. `0.3.18` заморожен (последний собираемый артефакт).**
|
||
|
||
**Путь:** фикс формы (`120bc07`) едет вместе с **переездом формы в web4** (Opt 3). Ретрофит `require`→dynamic `import()` в 5 местах технически возможен на node16, но это часы на стек, который удаляется web4 — не рекомендуется. До web4-cutover — **остаёмся на 0.3.18**, admin-сессия код не правит ([[verify-on-real-client-not-own-curl-tests]]).
|
||
|
||
> Образ `0.3.19` (краш-лупный) лежит в `docker.stostayer.ru` — **НЕ деплоить**. Оставлен в registry по решению vitya (2026-06-17), не удаляем.
|
||
|
||
## Связи
|
||
|
||
- [[portainer-stack-management-vds]] — родственный паттерн (наш VDS), оттуда PowerShell-кодировочные гочи и redeploy-рецепт.
|
||
- Доступы/инфра клиента (детально): `stostayer.new/.wiki/concepts/client-infra-access.md`.
|
||
- Задача: `.tasks/stostayer-web-complaint-form-deploy.md` (🔵 blocked на victor/stostayer.new).
|