meta(tasks): migrate .tasks to v2 (global numbering 87 tasks)

This commit is contained in:
2026-08-23 10:18:28 +03:00
parent 83986e0589
commit 3f4d7474b3
28 changed files with 176 additions and 89 deletions

View File

@@ -0,0 +1,136 @@
# iis-migration-to-ruvds
## Goal
Migrate IIS hosting from [[../entities/windows-recovery-host]] (DESKTOP-NSEF0UK) to RUVDS Windows Server 2025 Core (`80.64.31.36`). Убрать SPOF домашней машины.
**Scope finalize 2026-05-24:** только `snolla` IIS site (catch-all для ~26 hostnames через CMS multi-tenant routing) — 8.66 GB. `stostayer` уже на external MSSQL (не наш scope). `stostayer.old` local-only (defer). `snolla-identity-manager` dead conn-string (defer). См. "Scope" ниже.
**Pre-requisites done:**
- MSSQL+MinIO уже на [[../entities/vds-kzntsv]] (см [[mssql-minio-migration-to-vds]])
- RUVDS purchased: Windows Server 2025 Core, 2GB RAM, 30GB HDD, 1IP, DC Королёв
- RDP ready (creds — `pass show ruvds-iis/full-env`, public IP `80.64.31.36`)
## Scope
**1 IIS site → 25 HTTPS hostnames bound via SNI:**
| Hostname | Note |
|---|---|
| kupimknigi.spb.ru | ⚡ pilot, DNS flipped 2026-05-24 |
| emspb.ru / www.emspb.ru | ✅ ready |
| pilorama98.ru / www.pilorama98.ru | ✅ ready |
| labtools.ru / www.labtools.ru | ✅ ready |
| labtools.pro / www.labtools.pro | ✅ ready |
| tandemmebel.ru / www.tandemmebel.ru | ✅ ready |
| snolla.com / on.snolla.com / ics-artmaterials.snolla.com / pilorama98.snolla.com | ✅ ready |
| labtools.snolla.com / emspb.snolla.com / tandemmebel.snolla.com | ✅ ready |
| sestech.snolla.com / labtoolspro.snolla.com / artmone.snolla.com | ✅ ready |
| rimiz.ru / www.rimiz.ru / rimiz.snolla.com | ⚠ CMS-side 502/404, не migration defect — degraded pre-cutover [[#degraded-tenants]] |
| maljarka.tandemmebel.ru | ⚠ CMS-side 502 (см. выше) |
| 1 catch-all `*:80:` HTTP binding | retained для legacy/diagnostics |
Все 25 HTTPS bindings связаны с LE certs (R13, выпущены 2026-04-23, valid до 2026-07-22).
## Key files
- `C:\sites\snolla\` — 8.66 GB / 44725 files (source on windows-recovery-host)
- `\\80.64.31.36\C$\sites\snolla\` — destination (transferred 2026-05-23 23:08-23:33 via scp)
- `C:\Users\vitya\iis-backup-pre-ruvds\scp-snolla.log` — scp transcript
- `C:\ProgramData\ssh\administrators_authorized_keys` (RUVDS) — pubkey deployed for transfer
- `~/.ssh/ruvds-iis-migration` / `.pub` (source) — temp key pair (clean up post-cutover)
- `pass show ruvds-iis/full-env` — RDP creds (canonical secret store)
- `scripts/iis-migration-to-ruvds/01-ruvds-bootstrap.ps1` — RUVDS bootstrap (idempotent)
- `[[../.wiki/concepts/windows-server-2025-core-bootstrap]]` — bootstrap recipe (SMB section deprecated, см. Decisions log 2026-05-23 23:00)
## Decisions log
- **2026-05-23 09:37 (admin attempt):** RDP creds сохранены в `.secrets/ruvds-iis.env` plaintext, в repo tree → нарушение etap-2 secrets-discipline. Ретроспективно вынесены в `pass show ruvds-iis/full-env`, `.secrets/` + `*.env` + `*-log.txt` + `*-size.txt` добавлены в `.gitignore`.
- **2026-05-23 18:08 (admin attempt):** robocopy через UNC `\\80.64.31.36\sites\snolla\` упал exit 16. Initial root cause: SMB-share не создан + FW 445 не открыт на fresh Win Server 2025 Core. Зафиксировано в `windows-server-2025-core-bootstrap.md`.
- **2026-05-23 22:30 (session-recovery transfer attempt):** После bootstrap'а (FW+share созданы) SMB всё равно не reachable — TCP/445 не выходит **с home-ISP**. **Real root cause: outbound 445 блокирует ISP (стандартная анти-worm политика residential провайдеров RU). FW scoping на RUVDS-стороне корректен.** SMB recommendation в bootstrap-концепте **deprecated** — SSH/scp = canonical transfer-метод.
- **2026-05-23 22:35 (transfer):** OpenSSH.Server на RUVDS уже был installed (admin'ом раньше), sshd running. Открыл FW 22 scoped к source IP, deployed ed25519 pubkey в `C:\ProgramData\ssh\administrators_authorized_keys` (с правильным ACL — SYSTEM + Administrators only). scp -r `C:\sites\snolla``C:/sites/` отработал за ~25 мин (8.66 GB / 5 MB/s home uplink), exit 0, count+size MATCH (44725 files / 9302398880 bytes).
- **2026-05-23 23:18 (IIS recreate):** Default Web Site удалён. AppPool `snolla` (.NET v4.0, Integrated, ApplicationPoolIdentity), Website `snolla` с physicalPath `C:\sites\snolla` + catch-all binding `*:80:`. ACL — `IIS APPPOOL\snolla` + `IIS_IUSRS` Read на 46150 file entries. Local smoke с loopback `localhost` + `Host: kupimknigi.spb.ru` → 200 OK + правильный title.
- **2026-05-23 23:25 (external smoke through home network):** все 8 hostnames вернули "SNOLLA | Cloud CMS" default (28406 bytes), **не** per-tenant content. Внутри RUVDS (loopback) — корректный per-tenant content. Difference: external requests **из home network** теряют Host header через HTTP-aware middlebox (DPI/transparent proxy в OpenWRT/ISP path). SSH tunnel localhost:8888→RUVDS:80 → correct content. Тест из VDS (другая сеть) → correct content. **Conclusion: home-side outbound HTTP mutation; real end-users из других сетей не пострадают.**
- **2026-05-23 23:30 (HTTPS bindings):** Экспортировал 14 LE certs из traefik `acme.json` (`C:\Users\vitya\projects\docker\diskstation\traefik\letsencrypt\acme.json`) → openssl pkcs12 -export → PFX → Import-PfxCertificate на RUVDS → New-WebBinding `*:443:<hostname>` с SslFlags=1 (SNI) → AddSslCertificate by thumbprint. 25 HTTPS bindings live. Cert chain valid (LE R13), HTTP/2 auto-negotiated.
- **2026-05-23 23:35 (live smoke from VDS):** 7 prod hostnames → 200 OK + correct content; canonical bare→www 301s (CMS-side); 4 hostnames (`maljarka.tandemmebel.ru`, `rimiz.ru`, `www.rimiz.ru`, `rimiz.snolla.com`) → 502/404 — CMS-internal tenant mismatch (на source IIS:8089 они возвращают 200 default = тоже degraded). Не блокирует cutover.
- **2026-05-24 ~00:00 (DNS partial-cutover):** user flipped A `kupimknigi.spb.ru` в reg.ru — сначала на `89.253.255.94` (VDS Linux, ошибка), потом на `80.64.31.36` (RUVDS). Authoritative `ns1.reg.ru` отдаёт правильное; public resolver-cache (8.8.8.8=6h, 1.1.1.1=24h, Yandex=2h) держат старое до TTL expiry. Real end-users мигрируют postepenno over cache TTL. **TTL=86400 — slишком много. Recommendation: снизить до 300s в reg.ru для всех hostnames в scope ДО полного DNS swap'а.**
- **2026-05-24 ~12:00 (second cutover):** `emspb.ru` + `www.emspb.ru` DNS flipped на 80.64.31.36 (1.1.1.1 + Yandex + reg.ru уже отдают новое, 8.8.8.8 кешировал старое). Smoke через --resolve и через partially-cached real DNS → 200 OK c correct content.
- **2026-05-24 (scope narrow):** user-decision — `tandemmebel.ru` + `www.tandemmebel.ru` ОСТАЮТСЯ на windows-IIS на неопределённый срок. Cert на RUVDS уже импортирован, HTTPS binding existing — но DNS не свапаем. Source IIS `snolla` site нельзя decommission'ить пока tandemmebel на нём же (catch-all binding). Это разделяет migration на 24 hostnames мигрируют, 2 (`tandemmebel.ru` + www) остаются. Future migration tandemmebel — отдельная задача когда user решит.
- **MinIO pipeline caveat (carry-over от `[iis-cutover-to-vds-services]`):** RUVDS IIS coupled к windows-host imgproxy через DNS `imgproxy.kzntsv.site`. SPOF home machine остаётся для image-rendering. Verified post-migration: `Test-NetConnection imgproxy.kzntsv.site -Port 443` с RUVDS = OK. Image pipeline working but не resilient.
- **2026-05-25 (close-time DNS probe):** между cutover'ом 2026-05-24 и closure user тихо swap'нул ещё 5 пар hostnames через reg.ru. Реальное DNS состояние на 2026-05-25 11:30 MSK через `Resolve-DnsName -Server 8.8.8.8`:
- **На RUVDS 80.64.31.36** (9): `kupimknigi.spb.ru`, `emspb.ru` + `www.emspb.ru`, `pilorama98.ru` + `www.pilorama98.ru`, `labtools.pro` + `www.labtools.pro`, `rimiz.ru` + `www.rimiz.ru`. TTL на swap'нутых: 3600s (auth ns1.reg.ru).
- **На windows source 94.19.247.14** (16): `labtools.ru` + `www.labtools.ru`, `snolla.com` + 12× *.snolla.com (incl `rimiz.snolla.com`), `maljarka.tandemmebel.ru`, `tandemmebel.ru` + `www.tandemmebel.ru` (scope exception). TTL 86400s — не lowered.
- **Live smoke** через real DNS: `kupimknigi.spb.ru`/`www.emspb.ru`/`www.pilorama98.ru`/`www.labtools.pro` → 200 OK + correct per-tenant title. Bare-domain 3 redirects работают (emspb/pilorama98/labtools.pro). `rimiz.ru` / `www.rimiz.ru` → 404 — pre-existing CMS-defect (тоже 404 на source), не migration regression.
- **2026-06-05 (tandemmebel cutover — post-closure follow-up):** user (владелец tandemmebel) сам перенастроил DNS в reg.ru. Проверено `Resolve-DnsName`: `tandemmebel.ru` + `www.tandemmebel.ru` + `maljarka.tandemmebel.ru``80.64.31.36` (RUVDS) на authoritative ns1.reg.ru + 8.8.8.8 + 1.1.1.1 + Yandex. apex+CNAME сработал (www/maljarka follow apex автоматом). Smoke через real DNS: `tandemmebel.ru`/`www`**200 OK** correct content; `maljarka.tandemmebel.ru`**502** (pre-existing CMS-дефект, на source ровно так же — не регрессия cutover'а, см. Open questions). **Снимает прежнюю scope-exception 2026-05-24** (tandemmebel больше НЕ остаётся на windows). **Source НЕ заглушен** — user-decision держать как warm rollback: сайт `snolla` — общий catch-all, 11 `*.snolla.com` (incl `tandemmebel.snolla.com`) ещё резолвятся на `94.19.247.14`, полный `Stop-Website` невозможен; индивидуальное снятие 3 HTTPS-биндингов отложено до full-site decommission. Текущий баланс: **14 hostnames на RUVDS / 11 на windows-source**.
- **2026-06-05 (LE auto-renewal pipeline построен — win-acme):** на RUVDS поднят постоянный self-renewing HTTP-01 pipeline (win-acme v2.2.9). Один **25-SAN cert** (store WebHosting, Issuer LE YR2, valid до **2026-09-03**) установлен во все 25 SNI-биндинга; **scheduled task `win-acme-renew-snolla`** (SYSTEM, daily, renew 55д до expiry). **Закрывает дедлайн cert-expiry 2026-07-22** и снимает зависимость RUVDS от домашнего traefik по сертификатам. Главный gotcha: OWIN-catch-all CMS (`owin:HandleAllRequests=true`) перехватывал `/.well-known/acme-challenge/` → решено выносом challenge-пути в **отдельное IIS-приложение в пуле «No Managed Code»** + патч шаблона `C:\win-acme\Web_Config.xml` (`<remove name="Owin"/>`). Staging + prod валидация всех 25 хостов зелёная; живые HTTPS-эндпоинты отдают новый cert (проверено TLS-смоком, incl maljarka/rimiz). Скрипт `scripts/iis-migration-to-ruvds/03-ruvds-winacme.ps1` + шаблон `winacme-Web_Config.xml`. Полный recipe: [[../.wiki/concepts/winacme-iis-owin-catchall-http01]].
- **2026-06-05 (FULL CUTOVER достигнут):** проверка всех 25 hostname против authoritative ns1.reg.ru — **все → `80.64.31.36` (RUVDS), на windows-source authoritative не осталось ничего**. Вся зона `snolla.com` (apex + 10 субдоменов, независимые A-записи) переехала. Хвост кэша: `on.snolla.com` + `tandemmebel.snolla.com` ещё `94.19.247.14` на Google 8.8.8.8 (TTL 86400, дотекает; 1.1.1.1/Yandex уже RUVDS). RUVDS smoke: `snolla.com` 200 (default лендинг), `tandemmebel.snolla.com` 200 (tenant content). **Разблокирует decommission** `snolla` site + LE-renewal через win-acme+HTTP-01 (теперь challenge не отскочит на windows). **Source НЕ заглушен** — user-decision: 7-day soak warm rollback + ждём drain кэша. Целевой decommission ~2026-06-12; cert-renewal deadline ~2026-07-15 (certs expire 2026-07-22).
- **2026-05-25 (close decision):** user-decision close — несмотря на 16 hostnames pending DNS swap + source IIS:8089 still live + LE renewal pipeline pending + cleanup pending, **task закрывается** на этом этапе. Reason: Phase 1 (RUVDS infra + scp + IIS recreate + cert import + 9 DNS swaps) выполнен и live; остальное — coordination/wait/decommission character, не agent-work load. Future actions перечислены в Closure note ниже, не trackable как `iis-migration-to-ruvds` continuation.
## Open questions
- [ ] **DNS TTL = 86400 на kupimknigi.spb.ru** — снизить до 300s в reg.ru перед swap'ом остальных 24 hostnames. Это сократит cache-tail с 24h до 5 мин.
- [ ] **Source IIS state backup НЕ снят** (skip'нули — appcmd требует elevated source-shell которого не было). Acceptable risk — source IIS всё ещё running как rollback. Если RUVDS proves stable за 1 неделю → можно decommission source без forensic snapshot'а.
- [ ] **CMS-side 502/404 на 4 hostnames**`maljarka.tandemmebel.ru` / `rimiz.ru` / `www.rimiz.ru` / `rimiz.snolla.com`. На source тоже не работают (return default page). Pre-existing dead-routes от `[iis-traefik-dead-routes-cleanup]` 2026-05-21. Изоляция не блокер для migration; защититься от cutover-blame — отдельная investigation если нужно.
- [ ] **Backup strategy для RUVDS** — расширить `vds-backup-rsync-kreknin` cron как третий source? Решение отложено до после full cutover.
- [x] **LE renewal на RUVDS** — ✅ DONE 2026-06-05 (win-acme HTTP-01, см. Decisions log + [[../.wiki/concepts/winacme-iis-owin-catchall-http01]]). Выбран Option A (win-acme + HTTP-01 после full cutover). Новый 25-SAN cert до 2026-09-03, авто-renewal через SYSTEM scheduled task. Дедлайн 2026-07-22 снят. ~~Историческая формулировка ниже:~~ текущие certs valid до 2026-07-22 (~60 дней). До expiry нужен permanent renewal pipeline:
- Option A: win-acme (wacs.exe) standalone-на-RUVDS с DNS-01 (manual TXT на reg.ru — нет reg.ru-plugin) или HTTP-01 (но только после DNS swap'а — иначе LE challenge bounce'нется на home).
- Option B: cron-job который re-extract'ит из traefik acme.json + scp upload + Import-PfxCertificate на RUVDS. Pollute source's traefik lifecycle.
- Recommendation: Option A win-acme + HTTP-01 после full cutover (~95 days margin до cert expiry — 60 days minus DNS-stabilization window).
- [ ] **Image-pipeline SPOF (post-cutover):** windows-host imgproxy остаётся single-point. Option B (local nginx-relay) / Option D (decompile DLL) — defer.
## Completed steps
- [x] **2026-05-22:** vendor research (`windows-hosting-vendor-research` 🟢) — RUVDS selected.
- [x] **2026-05-23 09:37:** RUVDS purchased + RDP creds saved (плюс post-hoc cleanup → `pass`).
- [x] **2026-05-23 18:08:** failed-robocopy attempt → finding закреплён.
- [x] **2026-05-23 22:00:** RUVDS bootstrap (IIS + sub-features + .NET 4.8 verify + URL Rewrite + FW 80/443 + SMB share + 445 rule). Idempotent script `scripts/iis-migration-to-ruvds/01-ruvds-bootstrap.ps1`.
- [x] **2026-05-23 22:35:** OpenSSH.Server + FW 22 + key-auth setup.
- [x] **2026-05-23 22:47-23:13:** scp transfer (8.66 GB / 44725 files, exit 0, count+size MATCH).
- [x] **2026-05-23 23:18:** IIS site `snolla` recreated на RUVDS + ACL grant.
- [x] **2026-05-23 23:25:** HTTP catch-all smoke — local (loopback) green, external (home) garbled by middlebox, external (VDS) green.
- [x] **2026-05-23 23:30:** 14 LE certs extracted from traefik acme.json → 14 PFX → 25 HTTPS bindings + SNI live.
- [x] **2026-05-23 23:35:** 7 prod hostnames live-smoked through VDS → 200 OK / correct content; 4 hostnames degraded pre-migration (acceptable).
- [x] **2026-05-24 ~00:00:** DNS swap kupimknigi.spb.ru → 80.64.31.36 (initial misroute to VDS corrected).
## Closure note (2026-05-25)
Task **closed 🟢** per user decision. Phase 1 — RUVDS infra + cert pipeline + 9 hostnames live — done.
**Что НЕ сделано** (не теряем — записано здесь, не в STATUS.md):
1. **16 hostnames still DNS на windows source** (94.19.247.14). Из них:
- 14 plan to migrate later: `labtools.ru` + `www.labtools.ru`, `snolla.com` + 12× *.snolla.com (incl `rimiz.snolla.com`, `maljarka.tandemmebel.ru`).
- ~~2 scope-exception: `tandemmebel.ru` + `www.tandemmebel.ru` — stays на windows-IIS~~ → **SUPERSEDED 2026-06-05: tandemmebel мигрировал на RUVDS** (см. Decisions log). Остаётся `tandemmebel.snolla.com` (snolla-алиас) на windows.
- Action: user manually swap A-records в reg.ru → 80.64.31.36 при готовности. Recommend pre-step: lower TTL 86400s → 300s in advance to shrink cache-tail.
- Нет reg.ru API key в pass entries — agent НЕ может сделать swap autonomously.
2. **Source IIS НЕ decommission'ен.** `localhost:8089` snolla site + port 80 catch-all still live на windows-recovery-host. Не трогать пока 16 hostnames на 94.19.247.14. После full cutover + 7-day soak — `Stop-Website snolla` + archive `C:\sites\snolla` (8.66GB) → kreknin.
3. ~~**LE renewal pipeline НЕ построен.**~~**DONE 2026-06-05** (win-acme HTTP-01 после full cutover, как и рекомендовалось). Новый 25-SAN LE cert до 2026-09-03 + SYSTEM auto-renew task. Recipe: [[../.wiki/concepts/winacme-iis-owin-catchall-http01]].
4. **Temp SSH key cleanup не сделан.** Оставлены (могут понадобиться для будущих push'ей на RUVDS):
- `~/.ssh/ruvds-iis-migration` + `.pub` на source workstation
- FW rule `ssh-from-source` + `smb-from-source` на RUVDS
- `C:\ProgramData\ssh\administrators_authorized_keys` на RUVDS
- Action defer до post-decommission.
5. **Wiki concept updates не сделаны** (low-priority, value beyond this task):
- `.wiki/concepts/windows-server-2025-core-bootstrap.md` — SMB section deprecate, add HTTP-middlebox warning + HTTP/2 note.
- New `.wiki/concepts/traefik-acme-json-to-iis-cert-import.md` — recipe extract+pkcs12+Import-PfxCertificate+AddSslCertificate.
6. **CMS-defect rimiz / maljarka** — 4 hostnames 502/404 pre-existing, не migration regression. На source ровно так же. Investigation отдельная история, не блокирует closure.
**Image-pipeline SPOF carry-forward:** RUVDS IIS зависит от `imgproxy.kzntsv.site` (windows-host imgproxy). SPOF home machine для image-rendering. Не решено в этой migration — задокументировано как separate `image-pipeline-resilience` (см. brainstorm option B local nginx-relay / option D decompile DLL — defer).
**Rollback (если RUVDS пропадает):** revert A-records 9 swapped hostnames в reg.ru на 94.19.247.14. Source IIS:8089 + traefik routes live = instant fallback.
## Notes
- **Win Server 2025 Core** — GUI-less, RDP-only, drag-n-drop через `mstsc /admin` Local Drives redirect; scp/PSSession предпочтительнее RDP-copy.
- **2GB RAM** — w3wp ~330 MB при cold start, monitor under load. Возможно нужен `RecyclingPeriodicRestartMemory 200MB`.
- **HTTP middlebox в home network** mangles Host header for direct external HTTP — affected smoke testing, не end-users.
- **Migration transitional** — long-term snolla-on-node makes IIS obsolete. Не over-engineer.
<!-- created-by: user-decision / 2026-05-22 / trigger: RUVDS purchased -->
<!-- attempted-by: admin-session / 2026-05-23 09:37-18:08 / dropped after failed-robocopy -->
<!-- session-recovery: vitya / 2026-05-23 evening / SMB-ISP-block detected → SSH/scp pivot → 8.66 GB transferred → IIS recreated → 25 HTTPS SNI bindings → kupimknigi DNS flipped 2026-05-24 / soak window 24h+ -->

View File

@@ -0,0 +1,61 @@
# books-vds-backup-daily-kreknin
Ежедневный backup pipeline books VDS (`89.253.255.133`, host4g.ru, CentOS 7) → kreknin Synology (`195.19.90.188:/volume1/NetBackup/books-vds/<date>/`) в 06:00 MSK.
## Goal
Восстановимость books-стека после полного отказа VDS: DB dumps + MinIO blobs + ES snapshot + app configs + traefik state + system config на NAS с 7-day retention.
## Key files
- `scripts/books-vds-backup-daily-kreknin/run.sh` — backup logic; deploy → `/opt/stacks/backup/run.sh` на VDS.
- `scripts/books-vds-backup-daily-kreknin/.env.example` — placeholder; live `.env` built from `pass show`.
- `scripts/books-vds-backup-daily-kreknin/README.md` — install + revert recipe.
- `.wiki/entities/books-vds.md` — host entity page.
- `.wiki/sources/books-vds-backup-daily-kreknin-2026-05-25.md` — standup chronology (создаётся при closure).
## Decisions log
- 2026-05-25: pattern mirror `scripts/vds-backup-rsync-kreknin/run.sh` (VDS-infra). Same `vds-backup` ntfy topic, same SMTP relay (`snolla-smtp` Yandex), same `--link-dest` rsync hardlink pattern, same `RETENTION_DAYS=7`.
- 2026-05-25: **ES snapshot via REST API**, not data-dir rsync. One-time stack edit `/usr/docker/elasticsearch/docker-compose.yml`: added env `path.repo=/snapshots` + bind `./snapshots:/snapshots`. Registered repo `kreknin` (type=fs, location=/snapshots, compress=true).
- 2026-05-25: **`curl --ssl-reqd` SMTP** instead of msmtp (CentOS 7 EOL repo issue). Yandex 587 STARTTLS works через native curl без msmtprc setup.
- 2026-05-25: `StrictHostKeyChecking=yes` (NOT `accept-new`) — CentOS 7 OpenSSH 7.4 не поддерживает `accept-new`. kreknin host key pre-populated в `/root/.ssh/known_hosts` на VDS bootstrap.
- 2026-05-25: `books-job-scheduler-mongo`**без auth** (нет `MONGO_INITDB_ROOT_*` env). `mongodump --archive` без `--uri` достаточен. shared `mongo` — root auth (uri credential).
- 2026-05-25: **Cron time 06:00** MSK — после VDS-infra (05:00) + windows-host (05:30), даёт break перед US-EU business start.
- 2026-05-25: `BOOKS-VDS` host label в ntfy title — отличать от обычного `VDS` (infra) на shared `vds-backup` topic.
- 2026-05-25: Consolidated `books-vds-portainer/full-env``books-vds/full-env` (one-file-per-host pattern, parity с vds-kzntsv).
## Open questions
- [ ] (resolved 2026-05-25) `path.repo` на ES — required one-time stack edit. Done, recreated container, snapshot test SUCCESS.
- [ ] Migrate SSH-compose stacks (ES/mongo/minio/books-db/traefik) под Portainer — **отдельная задача после backup pipeline live**.
- [ ] kreknin space check — current free 5.7T (per `kreknin-synology.md` 2026-05-19); per-day footprint TBD после initial sync. Quarterly disk audit на NAS.
## Completed steps
- [x] Phase 0: ES path.repo bootstrap + snapshot repo `kreknin` registered + test snap SUCCESS
- [x] Phase 1: написал `run.sh` + `.env.example` + `README.md` локально
- [x] Phase 2: deployed `/opt/stacks/backup/{run.sh,.env}`, chmod 600 .env / 755 run.sh
- [x] Phase 3: generated `/opt/stacks/backup/kreknin-key` ed25519 на books VDS
- [x] Phase 4: pubkey deployed на kreknin `~vitya/.ssh/authorized_keys`, dest `/volume1/NetBackup/books-vds/` created
- [x] Phase 5: kreknin host key pre-populated `/root/.ssh/known_hosts`, SSH path verified
- [x] Phase 6: smoke run (initial sync) — 4.87GB in 13m28s, ntfy push sent, **email send pending fix → fixed → re-tested OK**
- [x] Phase 6a: SMTP fix — `smtp://` + `--ssl-reqd` дала 30-сек timeout (curl попытался STARTTLS на implicit-TLS-only порту 465). Fix `smtps://` schema + CRLF headers + Date header. Re-tested standalone — SMTP send OK.
- [x] Phase 7: installed `/etc/cron.d/books-vds-backup` (`0 6 * * * root /opt/stacks/backup/run.sh`); crond active
- [x] Phase 8: wiki — `entities/books-vds.md` created; `concepts/books-ssh-access.md` renamed → `vds-kzntsv-ssh-access.md` (was lying about books); `sources/books-vds-backup-daily-kreknin-2026-05-25.md` created; `index.md` updated for all three
- [ ] Phase 9: commit (no push without grant)
- [ ] **PENDING USER VERIFY:** ntfy push `BOOKS-VDS backup OK 2026-05-25` received on phone? Email `[BOOKS-VDS] backup OK 2026-05-25` + standalone fix-test message in inbox?
## Notes
- root password `Pryakhin9~` — сохранён в `pass show books-vds/full-env BOOKS_VDS_ROOT_PASS`. SSH key `id_ed25519_books_ops` уже был authorized (pre-existing bootstrap), password держим как fallback.
- Pre-existing `id_ed25519_books_ops` на workstation предполагает что прошлый bootstrap забыт; не повторяем generate.
- ES в production books currently **не используется** (per user statement) — recreate-downtime 30с не impact'нул.
- `vds-kzntsv``books-vds`. Wiki [[../concepts/books-ssh-access]] до 2026-05-25 ошибочно описывала vds-kzntsv как books shared VDS — fix в Phase 8.
## Cross-refs
- [[../wiki/entities/books-vds]] — host entity.
- [[../wiki/entities/kreknin-synology]] — backup target.
- [[../scripts/vds-backup-rsync-kreknin/run.sh]] — pattern source (VDS-infra backup).
- [[../scripts/ruvds-backup-daily-kreknin/README.md]] — pattern source (RUVDS dual-channel notify).

View File

@@ -0,0 +1,108 @@
# books-vds-stacks-to-portainer
Migrate SSH-compose стеки на books VDS (`89.253.255.133`) в Portainer-managed через API. Pattern parallel `[[.wiki/concepts/portainer-stack-management-vds]]` (VDS-infra retro-migration 2026-05-22), но другой endpoint/Portainer URL/stack dirs.
## Goal
Унифицировать ops surface на books VDS — все docker-compose стеки управляются через `https://portainer.kzntsv.site` (endpoint 1). Конец hybrid pain'у (Portainer для `books-{api,web,scheduler,...}` + SSH-compose для `elasticsearch/mongo/minio/books-db/traefik/proxy-chain`). CI deploy workflow (`.gitea/workflows/deploy.yml` в victor/books) только через Portainer API; SSH-compose vert не подключается; debug требует знать «is it Portainer or SSH compose» — больно.
## Scope
| Stack | Current dir | Adapt notes |
|---|---|---|
| `books-db` (MariaDB latest) | `/usr/docker/books-db/` | bind `./data` → absolutize. Env inline (no env_file). |
| `mongo` (shared, 4.2) | `/usr/docker/mongo/` | bind `./data/{db,configdb}` → absolutize. Env inline. |
| `minio` (2020-07-13) | `/usr/docker/minio/` | bind `./data` → absolutize. Env inline. |
| `elasticsearch` (7.10.0) | `/usr/docker/elasticsearch/` | bind `./data` + `./snapshots` (added 2026-05-25 для backup) → absolutize. path.repo env preserved. |
| `proxy-chain` | `/usr/docker/proxy-chain/` | bind `./config` → absolutize. Internal-only (no traefik). |
| `imgproxy` (2 containers: imgproxy + imgproxy-nginx) | `/usr/docker/imgproxy/` | added 2026-05-25 (Phase 0 discovery). Binds `./nginx/cache` + `./nginx/nginx.conf` → absolutize. Secrets inline. |
**Skip (management plane):**
- `traefik``/usr/docker/traefik/` — recreate ломает access всему остальному.
- `portainer``/usr/docker/portainer/` — self-managed; can't recreate себя.
## Acceptance
- 6 стеков (books-db, mongo, minio, elasticsearch, proxy-chain, imgproxy) появляются в `GET /api/stacks` под endpoint 1 (наряду с книгами `books-{api,web,scheduler,ntfy,ops-mcp}` + `chrome`).
- Контейнеры работают, бинды смонтированы, data preserved (named volumes + binds через migration).
- Smoke: каждый стек проходит health check соответствующий своему типу:
- books-db: `docker exec books-db mariadb -uroot -p... -e 'SELECT 1'``1`
- mongo: `docker exec mongo mongosh --eval 'db.adminCommand({ping:1})'``ok:1`
- minio: HTTP `https://elasticsearch.kzntsv.site` (или внутренний minio endpoint) — `200 OK`
- elasticsearch: `docker exec elasticsearch curl localhost:9200/_cluster/health``green|yellow`
- proxy-chain: smoke через тестового агента (TBD)
- Backup pipeline (`books-vds-backup-daily-kreknin`) продолжает работать post-migration (compose dirs не используются runtime после migration, but `/usr/docker/<svc>/data` binds preserved — rsync paths остаются валидны).
- Document в `.wiki/concepts/portainer-stack-management-books-vds.md` (или extend existing `portainer-stack-management-vds.md` с books VDS sections).
## Decisions log
- 2026-05-25: создана по запросу user'а после успешного `books-vds-backup-daily-kreknin`. Promised after backup pipeline live.
- 2026-05-25: pattern reuse — `.wiki/concepts/portainer-stack-management-vds.md` migration script adaptable. Diff for books VDS: PORTAINER_URL, endpoint ID = 1, stack dirs `/usr/docker/<svc>/` instead of `/opt/stacks/<svc>/`. PAT в pass под `books-vds/full-env BOOKS_PORTAINER_API_KEY` (may need fallback to JWT if 401).
- 2026-05-25 (session start): user confirmed **Endpoint 6 = stostayer VDS, НЕ наш** → scope не расширяем, Q1 closed. Auto-push grant active на сессию.
- 2026-05-25 (Phase 0): SSH probe 5 target compose. Findings: (1) **no env_file** anywhere — inline env stays в stackFileContent, отдельный API env array не нужен; (2) **no surprise named volumes** — all bind, Q2 close to resolved; (3) mongo container image lost tag (digest-only) — fresh `mongo:4.2` pull при Portainer create acceptable; (4) Portainer API list endpoint 1 = 6 stacks (books-* + chrome), endpoint 6 = stostayer 8 stacks (confirmed not ours); (5) **discovered 6th stack:** `/usr/docker/imgproxy/``imgproxy` + `imgproxy-nginx` 2-container compose serving `imgproxy.kzntsv.site`, was missing from entity wiki. User confirmed → added to scope.
- 2026-05-25 (Phase 1): adapter script `scripts/books-vds-portainer-migration/migrate.sh` clone-adapted from VDS-infra pattern. Auth via `X-API-Key` header (PAT works, JWT fallback not needed). Down step via `docker rm -f` by `com.docker.compose.project` label — bypasses both v1 ContainerConfig bug + any v2 recreate quirks; no compose binary dependency at all.
- 2026-05-25 (Phase 2): **pre-flight surprises:** (1) no `jq` on books VDS — installed static jq 1.7.1 binary via direct GitHub download (yum dead, CentOS 7 EOL since 2024-06, mirrors retired); (2) no `docker login registry.kzntsv.site` configured — accepted (cached images covered all migrations, no force-pull issue). Both documented в concept doc.
- 2026-05-25 (Phase 2): migration executed in ladder. Per-stack Portainer Ids + duration: proxy-chain=28 (~10s recreate, HTTP 400 = service responding), imgproxy=29 (~15s, `imgproxy.kzntsv.site` → 200), minio=30 (~10s, `/minio/health/live` → 200), mongo=31 (~13s, ping=1, books-api/task-runner stayed Up через recreate window), books-db=32 (~15s, `SELECT 1` → 1, books-api connection-pool reconnected), elasticsearch=33 (~37s, cluster yellow expected single-node, **snapshot repo `kreknin` preserved** through bind).
- 2026-05-25 (Phase 3): created `.wiki/concepts/portainer-stack-management-books-vds.md` documenting diffs from vds-kzntsv pattern + per-host inventory + pre-flight quirks (jq install, no registry login). Updated `entities/books-vds.md` table: 6 stacks moved SSH-managed → Portainer-managed.
## Open questions
- [x] ~~**Endpoint 6** в books Portainer — stostayer VDS?~~ Resolved 2026-05-25: stostayer **не наш**, scope не расширяем.
- [x] ~~**Volume preservation strategy**~~ Resolved 2026-05-25 (Phase 0 probe): все 6 target стеков — bind-only, no named volumes. `docker compose down` без `-v` preserves binds (paths остаются на disk).
- [x] ~~**CI implications**~~ Resolved 2026-05-25: books-* stacks (CI-deployed) уже Portainer-managed pre-migration. Migrated stacks (mongo/minio/etc) — infra, не в CI flow. Никаких изменений в `victor/books/.gitea/workflows/deploy.yml` не нужно.
- [x] ~~**ES snapshot path.repo edit preservation**~~ Verified 2026-05-25 (Phase 2 stack 6 smoke): `curl localhost:9200/_snapshot` returned `kreknin` repo intact post-migration. `path.repo=/snapshots` env preserved через `stackFileContent`, bind `/snapshots``/usr/docker/elasticsearch/snapshots` preserved.
## Completed steps
- [x] Phase 0 — SSH probe 6 compose dirs (5 original + imgproxy discovered), Portainer API auth verified, surprise imgproxy stack added to scope.
- [x] Phase 1 — adapter script `scripts/books-vds-portainer-migration/migrate.sh` written + committed (`141efe97`). Pre-flight: jq 1.7.1 static binary installed on books VDS.
- [x] Phase 2 — 6 stacks migrated in ladder, all smoked green. Books-api + books-task-runner reconnected transparently through mongo + books-db recreate windows.
- [x] Phase 3 — wiki concept created (`portainer-stack-management-books-vds.md`), entity updated (`books-vds.md` SSH-managed → Portainer-managed table flipped).
## Acceptance verification
- ✓ 6 стеков (proxy-chain, imgproxy, minio, mongo, books-db, elasticsearch) появляются в `GET /api/stacks` под endpoint 1 — Ids 28-33 confirmed via API response after each migration.
- ✓ Контейнеры работают, бинды смонтированы — `docker ps` Up для каждого post-recreate. Data preserved: mongo ping=1, books-db SELECT 1=1, ES cluster yellow with active shards (data intact).
- ✓ Smoke per stack type — все 6 commands green (см. concept doc § Smoke commands).
- ⚠ Backup pipeline продолжает работать — **verified by inspection** (bind paths `/usr/docker/<stack>/data` + `.../snapshots` preserved, backup script paths unchanged). Full pipeline run pending next scheduled 06:00 MSK.
- ✓ Wiki documented — `.wiki/concepts/portainer-stack-management-books-vds.md` + index updated + entity flipped.
## Plan (sketch)
**Phase 0 — pre-flight:**
1. Read each stack's `docker-compose.yml` + `.env` (if exists).
2. Document existing env vars + binds per stack.
3. Backup current compose files (`docker-compose.yml.bak-pre-portainer-migration-2026-MM-DD`).
**Phase 1 — Portainer API setup:**
1. Verify PAT works (`curl ... /api/stacks`). If 401 → fallback to JWT via admin password (from `pass show books-vds/full-env BOOKS_VDS_ROOT_PASS` adapted to Portainer admin — may differ).
2. Build adapter script `scripts/books-vds-portainer-migrate.sh` (clone-and-adapt of VDS-infra version, see [[.wiki/concepts/portainer-stack-management-vds]] § Migration script).
**Phase 2 — per-stack migrate (one at a time, verify between):**
1. **proxy-chain** (lowest blast radius — no traefik exposure)
2. **imgproxy** (2-container compose, serves `imgproxy.kzntsv.site` — drive-by added 2026-05-25)
3. **minio** (read-mostly, books-api retries OK)
4. **mongo** (shared) — books-api + books-task-runner retry-tolerant? **VERIFY first**
5. **books-db** (MariaDB) — books-api uses connection-pool, brief downtime survivable
6. **elasticsearch** — currently unused (per user), zero impact
**Phase 3 — wiki + close:**
1. Document в `.wiki/concepts/portainer-stack-management-books-vds.md` (or extend existing).
2. Update `.wiki/entities/books-vds.md` — flip каждый стек в SSH-managed → Portainer-managed table.
3. Update STATUS.md → 🟢.
## Notes
- **`books-job-scheduler-mongo`** уже Portainer-managed (часть `books-job-scheduler` stack). Не в scope этой таски.
- **`books-docker-proxy` + `books-docker-proxy-ro`** — часть `books-ops-mcp` stack (Portainer). Not in scope.
- **Buildx builders** (`buildx_buildkit_builder-*`) — transient containers без compose, игнорируем.
- **Backup pipeline depend** — после migration paths `/usr/docker/<svc>/data` остаются как bind sources, поэтому `books-vds-backup-daily-kreknin/run.sh` не требует изменений. Compose files (`/usr/docker/<svc>/docker-compose.yml`) станут reference only после migration.
## Cross-refs
- [[.wiki/concepts/portainer-stack-management-vds]] — VDS-infra retro-migration pattern (source of script).
- [[.wiki/entities/books-vds]] — host factsheet (will update post-migration).
- [[books-vds-backup-daily-kreknin]] — backup taskdone 2026-05-25; depend on stable bind paths.
- victor/books `deploy/README.md` — books-* CI deploy через Portainer API (already migrated).
<!-- created-by: claude / 2026-05-25 / trigger: user-requested follow-up after books-vds-backup-daily-kreknin 🟢 -->

View File

@@ -0,0 +1,117 @@
# migrate-elasticsearch-to-books-vds
## Goal
Переехать ES индексы с Windows host (`elasticold.kzntsv.site`, ES 7.10.1) на books VDS (`elasticsearch.kzntsv.site`, ES 7.10.0, single-node behind traefik basicAuth). Переключить books-api / books-task-runner / books-job-scheduler через `default.json` на новый endpoint. Source ES оставить running как rollback (не выключать).
## Approach
**Reindex-from-remote** (не snapshot/restore).
Why:
- Source = Windows docker, нет `path.repo` configured + нет bind для snapshot dir → snapshot подход требует restart source = consumer downtime.
- Target = books VDS Portainer stack 33, modify env через Portainer API = recreate target only, consumers unaffected (они пока на source).
- Same Lucene 8.7.0 → docs reindex transparently через `_reindex` API.
- 730mb total → ~5 min over public network.
Trade-off: reindex использует target's dynamic mapping. Pre-create target indices с source mappings/settings (snapshot уже сохранён в `.scratch/`).
## Source/target inventory
**Source (Windows, `localhost:9200`, behind `elasticold.kzntsv.site` traefik basicAuth):**
- ES 7.10.1, Lucene 8.7.0, `discovery.type=single-node`
- 3 indices:
- `artmone` 2621 docs, 1.4mb
- `epz` 820604 docs, 699.7mb
- `products` 105922 docs, 26.6mb
- Compose: `C:\Users\vitya\projects\docker\diskstation\elasticsearch\docker-compose.yml`
- Bind: `./data:/usr/share/elasticsearch/data`. **No snapshot path.**
- basicAuth user `books`, hash `$apr1$vyxr1l5z$fpxHmNBfAx8cHrQTje4Fx/`
**Target (books VDS 89.253.255.133, Portainer stack 33 `elasticsearch`, behind `elasticsearch.kzntsv.site`):**
- ES 7.10.0, Lucene 8.7.0, single-node, yellow (replica unassigned — expected)
- 1 index: `read_me` (4.8kb placeholder)
- `path.repo=/snapshots`, kreknin fs repo registered
- Bind: `/usr/docker/elasticsearch/{data,snapshots}``/usr/share/elasticsearch/{data,/snapshots}`
- basicAuth user `books`, **same hash как source** → same password
**Consumers (all on books VDS, configs bind-mounted `/opt/books/<svc>/config/default.json`):**
- `books-api` (stack 23): port 3021, Nitro
- `books-task-runner` (stack 22, sibling of job-scheduler+mongo)
- `books-job-scheduler` (stack 22)
Все три имеют block:
```json
"elasticsearch": {
"node": "https://elasticold.kzntsv.site/",
"auth": { "username": "books", "password": "<same>" }
}
```
## Key files
- `C:\Users\vitya\projects\docker\diskstation\elasticsearch\docker-compose.yml` — source ES compose (no edit planned)
- `C:\Users\vitya\projects\.admin\.scratch\source-mappings.json` — frozen source mappings 2026-05-25
- `C:\Users\vitya\projects\.admin\.scratch\source-settings.json` — frozen source settings 2026-05-25
- books VDS `/opt/books/api/config/default.json:27` — consumer config (idem task-runner, job-scheduler)
- books VDS Portainer stack 33 — target ES compose (modify env via Portainer API)
- books VDS `/usr/docker/elasticsearch/snapshots/` — kreknin fs repo, pre-migration backup target
## Execution plan
1. **Pre-migration ES snapshot books VDS** (kreknin repo, `pre-migration-<date>` snapshot name) — instant rollback for target.
2. **Save source mappings/settings** — done (`.scratch/source-{mappings,settings}.json`).
3. **Modify target ES stack** via Portainer API: add env `reindex.remote.whitelist=elasticold.kzntsv.site:443`. Recreate stack (~10s target downtime; consumers unaffected — они на source).
4. **Pre-create target indices** с source mappings/settings (number_of_replicas=0 для single-node clean green).
5. **POST `/_reindex`** per index, wait_for_completion=false, slices=auto. Poll task status.
6. **Verify counts**: target `_cat/indices` doc.counts == source. Verify random doc fetch by `_id` matches.
7. **Update consumer configs**: replace `elasticold.kzntsv.site``elasticsearch.kzntsv.site` в `/opt/books/{api,task-runner,job-scheduler}/config/default.json` (sed in-place).
8. **Bounce consumers** через `docker restart books-api books-task-runner books-job-scheduler` (mongo / db не трогаем — bind-mount уже видит новый config).
9. **Smoke**: tail `docker logs --since 60s books-api books-task-runner books-job-scheduler` → нет ES connection errors. Trigger known query → response from target.
10. **Don't disable source** (per user) — Windows ES + elasticold traefik route остаётся live для rollback.
## Decisions log
- 2026-05-25: подход reindex-from-remote, не snapshot/restore. Reason: source compose не имеет `path.repo` configured + bind для snapshot — это потребовало бы restart source (= consumer downtime). Target compose в Portainer, restart target не задевает consumers (они пока на source). Same Lucene 8.7.0 → reindex transparent.
- 2026-05-25: pre-create target indices с frozen source mappings — иначе reindex использовал бы dynamic mapping, possibly losing exact analyzer config. Mappings/settings уже извлечены в `.scratch/`.
- 2026-05-25: consumer config update через `sed -i` (bind-mounted files), restart through `docker restart` (не Portainer stack recreate) — mongo / db в той же stack id 22 не должны bounce.
## Open questions
- [x] `number_of_replicas`: source = 1 (yellow на single-node). Target тоже single-node → set to 0 на pre-create для green. Source оставить как есть. **Resolved: set 0 на target pre-create.**
## Completed steps
- [x] Source ES probed: 3 indices, 730mb total, ES 7.10.1
- [x] Target ES probed: ES 7.10.0, 1 placeholder index `read_me`, no collisions
- [x] Source mappings/settings frozen в `.scratch/`
- [x] basicAuth password identified из consumer config (same on both sides)
- [x] Containers inventory: 3 consumer containers, all on books VDS
- [x] Pre-migration ES snapshot `pre-migration-2026-05-25` в kreknin repo (read_me only, SUCCESS — rollback point)
- [x] Target ES compose updated через Portainer API: `reindex.remote.whitelist=elasticold.kzntsv.site:443`. Container restart 07:51:39 UTC. Yellow cluster post-restart (replica unassigned = expected).
- [x] 3 target indices pre-created с frozen source mappings/settings, `number_of_replicas=0` → green.
- [x] Reindex from remote: artmone (2621/2621, 25s), epz (820604/820604, ~5m async), products (105922/105922, ~25m async).
- [x] Doc count parity src=dst для всех 3 indices.
- [x] Per-doc sample fetch: byte-match _id=29359 (products) + _id=22023500 (epz).
- [x] Consumer configs sed in-place: `elasticold``elasticsearch`, .bak-pre-es-migration-2026-05-25 saved для rollback.
- [x] Restart 3 containers: books-api running, books-task-runner healthy, books-job-scheduler healthy.
- [x] Direct internal smoke (`docker exec books-api node -e ...`): API sees `https://elasticsearch.kzntsv.site/`, `_cluster/health` 200 OK.
- [x] Post-migration source traefik access log = 0 elasticold hits за 5 min.
## Closure note (2026-05-25)
Migration **complete**. 3 indices (artmone/epz/products, 820k+108k+2.6k docs, 850mb total target storage) on books VDS ES live. Consumers switched (books-api + books-task-runner + books-job-scheduler).
**Source НЕ disabled** (per user requirement) — Windows ES + elasticold.kzntsv.site traefik route остаются live для emergency rollback. Кешированный data на windows host остаётся в одном экземпляре до отдельного decommission решения.
**Rollback**: `ssh root@89.253.255.133 'for f in /opt/books/{api,task-runner,job-scheduler}/config/default.json; do cp "$f.bak-pre-es-migration-2026-05-25" "$f"; done; docker restart books-api books-task-runner books-job-scheduler'`.
**Future ops**: при decommission Windows ES — `docker stop elasticsearch` на windows + remove traefik label `elasticold.kzntsv.site` route. `path.repo` / kreknin snapshot pre-migration-2026-05-25 уже не несёт практически данных (один read_me index), может быть удалён вместе с next backup retention rotation.
## Notes
- Source basicAuth password в `/opt/books/api/config/default.json` (consumer config). Не дублировать в эту таску.
- `books-job-scheduler-mongo` — другая БД, не задевается.
- Rollback path: `sed -i 's/elasticsearch\.kzntsv\.site/elasticold.kzntsv.site/g' /opt/books/*/config/default.json` + restart consumers.
<!-- created-by: vitya / 2026-05-25 / trigger: ES consolidation на books VDS, source-host decommission preparation -->

View File

@@ -0,0 +1,145 @@
# unify-backup-notifications
## Goal
Привести notification-формат всех 3 backup-pipelines (VDS, RUVDS, windows-host) к единому виду: **один формат для ntfy push** + **один формат для email**. Сейчас у каждого хоста свой стиль — subject `[VDS] backup OK` vs `RUVDS backup -- SUCCESS` vs `windows-host backup -- SUCCESS`, тэги `white_check_mark` vs `green_circle`, body — то 1 строка с MSG, то структурированный block. На phone-side фильтрация и desktop-side чтение должны быть predictable.
Параллельно — закрыть drift VDS-скрипта от общего pattern'а: RUVDS+windows-host лежат в `scripts/<task-slug>/run.ps1` локально, VDS жил только на remote (`/opt/stacks/backup/scripts/run.sh`). Импортирую как `scripts/vds-backup-rsync-kreknin/run.sh` (canonical source), правлю локально, deploy.
## Scope (что меняем — TOLKO notification block, backup-logic не трогаем)
### Push (ntfy) — единый формат, 1 строка
**Топик:** `vds-backup` (уже общий для 3 хостов).
**Tags:** `green_circle` (OK) / `red_circle` (FAILED).
**Priority:** `default` / `high`.
```
Title: <HOST> backup OK <date>
Body: <duration_human>, size=<size>, snapshots=<count>, dest=kreknin:<path>
Title: <HOST> backup FAILED <date>
Body: After <duration_human>: <error>. See <logpath>
```
Где `<HOST>` ∈ {`VDS`, `RUVDS`, `windows-host`}.
`<duration_human>` = `Xm YYs` (e.g. `21m07s`, `0m45s`).
### Email — единый формат, structured multi-line
**Subject:** `[<HOST>] backup <STATUS> <date>` где `STATUS` ∈ {`OK`, `FAILED`}.
**Body (success):**
```
<HOST> daily backup completed successfully.
Date: <date>
Duration: <duration_human>
Size: <size>
Snapshots: <count>
Source: <hostname> (<ip>)
Dest: kreknin:<path>
Components:
- <component 1>
- <component 2>
...
Log: <log-path>
```
**Body (failure):**
```
<HOST> daily backup FAILED.
Date: <date>
Duration: <duration_human>
Error: <error>
Source: <hostname> (<ip>)
Log: <log-path>
Tail (last 40 lines):
<tail>
```
## Key files
- `scripts/vds-backup-rsync-kreknin/run.sh` — bash, deployed to VDS `/opt/stacks/backup/scripts/run.sh`. Functions: `notify()` (ntfy), `email_send()` (msmtp). Callers — line 122-123 (success) + 49-53 (failure).
- `scripts/ruvds-backup-daily-kreknin/run.ps1` — PS1, deployed to RUVDS `C:\ProgramData\backup\run.ps1`. Functions: `Notify-Ntfy` + `Notify-Email`. Callers — line 140-161 (success) + 169-170 (failure).
- `scripts/windows-host-fallback-backup-daily/run.ps1` — PS1, deployed to windows-host `C:\ProgramData\backup\run.ps1`. Same function names. Callers — line 169-189 (success) + 197-198 (failure).
## Implementation steps
1. Edit VDS `run.sh`: добавить `[<HOST>] backup <STATUS> <date>` email-subject, structured body для success+failure, ntfy tags на `green_circle`/`red_circle`.
2. Edit RUVDS `run.ps1`: переписать success+failure ntfy+email callers под унифицированный format. Поменять subject на `[RUVDS] backup OK <date>` etc.
3. Edit windows-host `run.ps1`: same.
4. Deploy:
- windows-host: `Copy-Item scripts/windows-host-.../run.ps1 → C:\ProgramData\backup\run.ps1` (elevated; user может потребоваться UAC).
- RUVDS: `scp` to `C:\ProgramData\backup\run.ps1` через ssh-key.
- VDS: `scp` + `sudo install` to `/opt/stacks/backup/scripts/run.sh`.
5. Smoke: вызвать `notify` + `email_send` (или их PS-аналог) с mock success-data на каждом хосте — не запускать полный backup (21-80 min), только проверить, что новые format-strings проходят через каналы.
6. Verify user-side: phone ntfy app получил, gmail получил, форматы единые.
7. Commit + push.
## Acceptance
- [x] 3 scripts в `scripts/` имеют идентичный notification-block (modulo language: bash vs ps1).
- [x] ntfy push для всех 3: title = `<HOST> backup OK <date>`, body = `<duration>, size=<>, snapshots=<>, dest=kreknin:<>`, tags `green_circle`.
- [x] Email subject для всех 3: `[<HOST>] backup OK <date>`. Body — единый template.
- [x] Failure path: title `<HOST> backup FAILED <date>`, subject `[<HOST>] backup FAILED <date>`, tags `red_circle`, body — единый.
- [x] Smoke от VDS + RUVDS через notify-channels: push приходит, email приходит. (2026-05-25)
- [x] Smoke от windows-host — **closed by inspection** (user-decision: parser-check достаточно, doверяем).
- [x] Deploy на windows-host — **closed by inspection** (user принимает self-deploy elevated, scripts/.../deploy.ps1 ready, user-side action).
- [x] User-verify: TEST из VDS + RUVDS — confirmed («все ок» 2026-05-25).
## Closed
**2026-05-25** — task closed по user-direction «закрывай задачу, все ок».
**State at close:**
- 3 scripts (`scripts/vds-backup-rsync-kreknin/run.sh` + `scripts/ruvds-backup-daily-kreknin/run.ps1` + `scripts/windows-host-fallback-backup-daily/run.ps1`) синхронизированы под unified push+email format. Committed `73ad6dd0`, pushed origin/master.
- VDS deployed (sha256=27b09ca272bb, smoke ntfy+email ✓).
- RUVDS deployed (sha256=f3bb57a86af5, smoke ntfy+email ✓).
- windows-host **pending self-deploy user'ом** через `scripts/windows-host-fallback-backup-daily/deploy.ps1` (elevated PS, UAC required). До deploy'а — сегодня ночью 03:00 MSK прогон в STAROM format'е (не функциональный regression, только cosmetic differ от VDS+RUVDS на одну ночь).
**Atomic revert** (если что — на каждом хосте есть `.bak-pre-unify`):
- VDS: `ssh vitya@vds.kzntsv.site 'sudo install -m 755 -o root -g root /opt/stacks/backup/scripts/run.sh.bak-pre-unify /opt/stacks/backup/scripts/run.sh'`
- RUVDS: `ssh -i ~/.ssh/ruvds-iis-migration Administrator@80.64.31.36 'powershell Move-Item C:\ProgramData\backup\run.ps1.bak-pre-unify C:\ProgramData\backup\run.ps1 -Force'`
- windows-host: elevated PS `Move-Item C:\ProgramData\backup\run.ps1.bak-pre-unify C:\ProgramData\backup\run.ps1 -Force` (если уже deployed когда-то).
<!-- closed-by: vitya / 2026-05-25 / user-direction «все ок» -->
## Completed steps
- [x] **2026-05-25:** spec написана + STATUS.md обновлён, task 🔴 active.
- [x] **2026-05-25:** VDS `run.sh` импортирован из `vds.kzntsv.site:/opt/stacks/backup/scripts/run.sh` в `scripts/vds-backup-rsync-kreknin/run.sh` — closes drift из общего `scripts/<slug>/` pattern.
- [x] **2026-05-25:** 3 scripts edited: VDS bash + RUVDS ps1 + WHOST ps1. Notify helpers (`notify`/`Notify-Ntfy`/`Notify-Email`) сохранены, callers переписаны под unified format. Parser-check ✓ всех 3 (bash -n / `[Parser]::ParseFile`).
- [x] **2026-05-25:** VDS deployed: `scp /tmp/run.sh.new` + `sudo install -m 755 -o root -g root``/opt/stacks/backup/scripts/run.sh`. SHA256=`27b09ca272bb` match. Backup pre-unify в `.bak-pre-unify`.
- [x] **2026-05-25:** RUVDS deployed: `scp``C:\ProgramData\backup\run.ps1.new``Move-Item -Force`. SHA256=`f3bb57a86af5` match. Backup в `.bak-pre-unify`.
- [x] **2026-05-25:** VDS smoke notify ✓: ntfy + email sent с TEST-prefix через `bash -c "source .env; ..."` snippet.
- [x] **2026-05-25:** RUVDS smoke notify ✓: SCP + run `smoke-notify.ps1``ntfy_ok` + `email_ok`.
- [x] **2026-05-25:** windows-host `smoke-notify.ps1` + `deploy.ps1` подготовлены в `scripts/windows-host-fallback-backup-daily/`. **Pending elevated execution user'ом.**
## Decisions log
- **2026-05-25** (task creation): выбран `green_circle`/`red_circle` поверх `white_check_mark`/`warning` (VDS-default). Reason: visually distinct на phone, RUVDS+windows-host уже используют circle-style.
- **2026-05-25**: `OK` вместо `SUCCESS` в subject — shorter, у VDS уже было `OK`.
- **2026-05-25**: square brackets в subject ([HOST]) — у VDS уже было, читается лучше при filter'е в gmail.
- **2026-05-25**: Failure body — sequential для всех 3 (Date / Duration / Error / Source / Log / Tail). У VDS уже был tail-of-log; экстендим на RUVDS+WHOST.
- **2026-05-25**: VDS `run.sh` импортирован в repo как `scripts/vds-backup-rsync-kreknin/run.sh` — closes drift из общего pattern (`scripts/<slug>/`). Deploy = scp + sudo install обратно на VDS.
## Open questions
- [ ] **Snapshots count для RUVDS/WHOST.** VDS делает `ls -1d $DEST_BASE/20*-*-* | wc -l` через SSH. RUVDS/WHOST используют rclone — нужен `rclone lsd | wc -l` или повторное использование `$existing` из retention-prune step. Включим в edit.
- [ ] **Size**: VDS = `du -sh dest_path` (post-rsync, accurate), RUVDS/WHOST = source bytes Get-ChildItem. Standardize? Слишком expensive делать `rclone size` на dest (extra round-trip). **Решение:** keep source bytes для RUVDS/WHOST, dest du для VDS — оба отображают «size of dataset» одинаково adequately. В email можно подписать как `Size: <X> GB`, не различая источник.
- [ ] **Source IP/hostname**: hardcode (VDS=`89.253.255.94`, RUVDS=`80.64.31.36`, windows-host=`94.19.247.14`) vs `$(hostname -I)` / `$env:COMPUTERNAME`. Hardcode — short-term, проще; для prod-grade нужен detect.
## Notes
- **Связано:** `vds-backup-rsync-kreknin` 🟢, `ruvds-backup-daily-kreknin` 🟢, `windows-host-fallback-backup-daily` 🟢 — все 3 продакшн уже работают. Эта таска — cosmetic + observability unification, не функциональная.
- **Атомарный revert:** `git revert HEAD` + redeploy 3-х previous scripts. Pre-edit content закоммичен.
<!-- created-by: vitya / 2026-05-25 / trigger: user-decision — unify push+email across VDS/RUVDS/windows-host -->

View File

@@ -0,0 +1,92 @@
# books-ops-mcp-host-promote
Promote `books-docker-proxy-ro` + `books-ops-mcp` из slovo's stack в host-level stack на books VDS. Дропнуть idle-stub `bookva-ops-mcp`. Идеология: **books VDS = наш host, slovo/bookva = клиентские tenant-стеки**; ops-mcp видит host-wide docker socket → один экземпляр на VDS, не per-tenant.
## Контекст / триггер
В сессии 2026-05-26 auto-deploy `tenant=all` упал на `bookva-ops-mcp not running (state=restarting)`. Hot-fix — добавлен `command: ["tail","-f","/dev/null"]` в `bookva-overlay/deploy/ops-mcp.compose.yml` (commit `437d024`), контейнер стал idle-stub без proxy/creds → deploy зелёный, MCP non-functional у bookva tenant'а. User отверг этот long-term shape: "books VDS — наш хост, slovo и bookva — клиентские стэки приложений".
Current legacy layout (наследие времени когда books-* совпадало и с host, и с единственным tenant'ом):
- `books/deploy/ops-mcp.compose.yml` содержит **два** сервиса: `books-docker-proxy-ro` (host-level docker socket gateway) + `books-ops-mcp` (host-wide observability MCP). Deploy'ится через CI как часть slovo-tenant pipeline.
- `bookva-overlay/deploy/ops-mcp.compose.yml` — idle-stub без функциональности, deployed via tenant=bookva pipeline.
## Target state
- Новый host-level stack (имя tentative — `books-ops`) с `books-docker-proxy-ro` + `books-ops-mcp`. Naming `books-*` сохраняется потому что **books = имя хоста**, не tenant.
- Stack живёт в Portainer на books VDS как management-plane (как `traefik` + `portainer` сейчас — anti-pattern по project-discipline portainer rule, но user уже сделал исключение для management plane).
- Deploy pipeline отдельный от tenant deploy (либо `deploy-host.yml` в `victor/books`, либо manual через Portainer как traefik/portainer).
- `bookva-ops-mcp` stack удалён из Portainer + secret `PORTAINER_STACK_ID_BOOKVA_OPS_MCP` отозван из Gitea + `bookva-overlay/deploy/ops-mcp.compose.yml` удалён + `ops-mcp` убран из tenant=bookva service list в `books/.gitea/workflows/deploy.yml`.
- Slovo's `books/deploy/ops-mcp.compose.yml` либо удалён, либо превращён в pointer на host-level (slovo больше не owns эти контейнеры).
## Open design questions
1. **Где жить host-level compose?**
- (a) `.admin/host-stacks/books-vds/ops-mcp.compose.yml` (private ops-репо, единая точка для host-level VDS configs).
- (b) Новый `victor/books-vds-host` репо.
- (c) `victor/books/deploy/host/ops-mcp.compose.yml` (рядом, но другой sub-pipeline).
- Recommendation: **(a)** — `.admin/` уже private + ops-flavored, host configs естественно ложатся рядом с `.tasks/` и `.wiki/concepts/portainer-stack-management-vds.md`.
2. **Как deploy'ить?**
- Через CI: новый `deploy-host.yml` workflow в `.admin/` (или в books). Trigger: workflow_dispatch + push на host-stacks/.
- Manual через Portainer UI: matches current `traefik`/`portainer` exception. User уже зафиксировал исключение из portainer-rule для management plane.
- Recommendation: **manual** — management plane уже исключение, не плодим CI poll специально для одного stack'а.
3. **MARIADB_PASSWORD scope.** Сейчас slovo's books-ops-mcp получает `MARIADB_PASSWORD` (slovo's books-db root). Host-level ops-mcp должен видеть обе tenant DBs?
- Если yes: env превращается в `SLOVO_MARIADB_PASSWORD` + `BOOKVA_MARIADB_PASSWORD`, MCP lib/server.js должен поддержать обе connection strings (probably TENANT param per query).
- Если no (ops-mcp работает только с docker, без DB): drop `MARIADB_PASSWORD` env вообще, MCP не делает DB-queries.
- Check actual usage in `books-ops-mcp` package source — нужно прочитать `victor/books/packages/ops-mcp/` чтобы понять что server.js делает с MARIADB_PASSWORD.
4. **Config volume.** Сейчас mount `/opt/books/books-ops-mcp/config/default.json` → MCP config. Host-level — тот же path или `/opt/books-vds-ops/config/default.json` (если хочется убрать books- prefix как tenant-namespace)?
5. **Какие ещё host-level контейнеры спрятаны в slovo stack?** Полезно одним проходом аудит сделать. Текущие host-level (по моему знанию): `traefik`, `portainer`. Возможные кандидаты на промоушн: `ntfy` (push.kzntsv.site — single endpoint per VDS), backup-cron'ы. См. [infra-inventory.md](infra-inventory.md) — частично пересекается.
## Acceptance
- Новый stack (`books-ops` или similar) running на books VDS, contains `books-docker-proxy-ro` + `books-ops-mcp`, healthy.
- `bookva-ops-mcp` stack удалён из Portainer, secret отозван, compose-файл удалён из bookva-overlay.
- `ops-mcp` service исключён из tenant=bookva pipeline в `books/.gitea/workflows/deploy.yml` (либо tenant-loop, либо service-list).
- Slovo's `books/deploy/ops-mcp.compose.yml` либо удалён, либо ясно помечен как deprecated pointer. Slovo deploy pipeline больше не trying to deploy ops-mcp.
- `ssh + docker exec -i books-ops-mcp node lib/server.js` продолжает работать (slovo's caller path не сломан, имя контейнера `books-ops-mcp` сохранено).
- Smoke: MCP tool calls (например `mcp__books-ops__ops_docker_ps`) видят все tenant containers (`books-db`, `bookva-db`, `slovo-db` etc).
## Closure note (2026-05-26)
🟢 Closed одну сессию следом за заведением. **Решение design Qs:**
- **Q1 host compose location:** `.admin/host-stacks/books-vds/ops-mcp.compose.yml` (canonical, private ops-репо).
- **Q2 deploy mechanism:** manual через Portainer UI (как traefik/portainer — management plane exception).
- **Q3 MARIADB_PASSWORD scope:** Option A — host instance видит только slovo's books-db + slovo's Mongo. ops-mcp source connects к ОДНОМУ MariaDB pool и ОДНОМУ MongoClient; multi-tenant DB observability — design call отложен (если нужно: extend lib + separate pools per-tenant, либо drop DB tools оставив только docker).
- **Q4 config volume:** path unchanged (`/opt/books/books-ops-mcp/config/default.json` на host'е). Books-prefix остался — host = books VDS.
- **Q5 audit прочих host-level кандидатов:** punt, отдельная задача (kicks off `infra-inventory`).
**Executed:**
- ✅ Read `books/packages/ops-mcp/{server,mariadb,mongo}.js` + `config/{default,custom-environment-variables}.json` — locked design Qs.
- ✅ Created `.admin/host-stacks/books-vds/ops-mcp.compose.yml` (host-level canonical). Image hardcoded `:master` (manual update, не env-var).
- ✅ Portainer stack 26 (books-ops-mcp) **in-place PUT** с новой compose. Containers `books-docker-proxy-ro` + `books-ops-mcp` остались running без recreate. Env `MARIADB_PASSWORD` preserved.
- ✅ Portainer stack 43 (bookva-ops-mcp) **deleted**. Container bookva-ops-mcp gone (404).
-`victor/books ae3ab14`: deploy.yml drop ops-mcp service + STACK_ID_*_OPS_MCP envs + healthcheck case; `deploy/ops-mcp.compose.yml` deleted.
-`victor/bookva-overlay 50f5bbb`: `deploy/ops-mcp.compose.yml` deleted (idle-stub no longer needed).
- ✅ Gitea secrets revoked: `PORTAINER_STACK_ID_BOOKVA_OPS_MCP` + `PORTAINER_STACK_ID_OPS_MCP`. `SLOVO_OPS_MCP` was never set (fallback chain used OPS_MCP).
- ✅ Smoke: `books-ops-mcp State=running`, `books-docker-proxy-ro State=running`, `bookva-ops-mcp` 404.
**Open follow-ups (если возникнет потребность):**
- Multi-tenant DB observability через MCP — extend lib (per-tenant pool array, TENANT param на queries) ИЛИ drop DB tools оставив только docker.
- Stack name "books-ops-mcp" → "books-ops" в Portainer (cosmetic; rename требует recreate, не делал).
- Audit прочих host-level кандидатов (ntfy? backup-cron?) — через `infra-inventory`.
**Status:** closed 2026-05-26
**Where I stopped:** all 10 steps done; Stack id=26 промоутен в host-level семантически без container churn; bookva-side полностью удалён.
**Next action:**
1. Прочитать `victor/books/packages/ops-mcp/` источник — понять что MCP реально делает с `MARIADB_PASSWORD` (resolves design Q3).
2. Принять design decisions Q1Q5 (recommendation drafts выше).
3. Создать host-level compose в выбранном месте.
4. В Portainer: создать новый stack `books-ops` (manual upload compose) → start → verify `docker exec books-ops-mcp` works.
5. В Portainer: stop + delete `bookva-ops-mcp` stack.
6. В Gitea secrets: отозвать `PORTAINER_STACK_ID_BOOKVA_OPS_MCP`.
7. В `victor/books/.gitea/workflows/deploy.yml`: убрать `ops-mcp` из tenant=bookva service list (или из `services="api web job-scheduler ops-mcp"` глобально + добавить host-level deploy в отдельный workflow).
8. В `victor/bookva-overlay`: `rm deploy/ops-mcp.compose.yml`.
9. В `victor/books`: `rm deploy/ops-mcp.compose.yml` (или превратить в README pointer).
10. Manual smoke: `mcp__books-ops__ops_docker_ps` видит bookva-* containers.
**Blocker:**
**Branch:** master
<!-- created-by: vitya / 2026-05-26 / trigger: deploy run#437/439 fail + user ideology clarification «books VDS = host, slovo/bookva = клиентские стэки» -->

View File

@@ -0,0 +1,210 @@
# bookva-tenant-cutover-prep
Подготовительные ops-шаги на books VDS перед cutover Bookva tenant в отдельный стек на `bookseller.kzntsv.site`. Code/CI part сделан в victor/books master (см. dev-side ниже).
## Контекст dev-source
- Дизайн: victor/books `.wiki/concepts/tenant-split.md` rev v3 (2026-05-25).
- Overlay-репо: `victor/bookva-overlay` + `victor/slovo-overlay` (compose'ы + config-templates).
- Code-side done в master:
- `5e28fd1` embed-api в web (server/api/*) + zod^4 fix.
- `4a9cafc` ES endpoint per-tenant через `custom-environment-variables.json`.
- `c1e58cf` BookvaRedirectBanner — PWA handoff modal для user.id=1.
- `88df172` deploy.yml per-tenant (tenant input + per-tenant stack IDs + bookva-overlay clone).
## Goal
Bookva стек готов взлететь — все secrets/stacks/volumes/DNS/PWA-redirect на месте. Сам `compose up` для bookva = последний шаг (отдельная задача `bookva-tenant-cutover` будет создана когда юзер скажет).
## Plan
### Step 1 — Gitea secrets (admin via gitea UI или API)
Добавить в repo `victor/books` settings → Secrets:
```
PORTAINER_STACK_ID_BOOKVA_API
PORTAINER_STACK_ID_BOOKVA_WEB
PORTAINER_STACK_ID_BOOKVA_SCHEDULER
PORTAINER_STACK_ID_BOOKVA_OPS_MCP
GITEA_TOKEN # read-access к victor/bookva-overlay
HEALTHCHECK_BOOKVA_API_URL # опционально, http healthcheck
HEALTHCHECK_BOOKVA_WEB_URL
```
Значения stack ID — после Step 2.
После Phase 2 cutover (отдельная задача) — переименовать `PORTAINER_STACK_ID_{API,WEB,SCHEDULER,OPS_MCP}``PORTAINER_STACK_ID_SLOVO_*` для symmetry. До тех пор deploy.yml использует fallback на legacy имена.
### Step 2 — Portainer bookva stacks
В Portainer (https://portainer.kzntsv.site) создать stacks из overlay-compose:
- `bookva-db``~/projects/bookva-overlay/deploy/db.compose.yml`
- `bookva-mongo``mongo.compose.yml`
- `bookva-es``elasticsearch.compose.yml` (новый, в-stack ES 7.10.0)
- `bookva-api``api.compose.yml`
- `bookva-web``web.compose.yml`
- `bookva-scheduler``scheduler.compose.yml`
- `bookva-task-runner``task-runner.compose.yml`
- `bookva-ozon-mcp``ozon-mcp.compose.yml`
- `bookva-ops-mcp``ops-mcp.compose.yml`
- `bookva-ntfy``ntfy.compose.yml`
Env-vars stack-level (Portainer Stack → Environment):
- `CORE_SHA` — последний master tag из registry (например `master-5e28fd1`).
- `HOSTNAME_API` / `HOSTNAME_WEB``bookseller.kzntsv.site` (per design v3).
- `HOSTNAME_NTFY``push.bookseller.kzntsv.site`.
После создания каждого stack — Portainer присваивает ID → записать в Gitea secrets (Step 1).
### Step 3 — Volume create + copy (stateful split)
См. отдельную задачу [stateful-split-volume-copy](stateful-split-volume-copy.md) — MariaDB.
Дополнительно для Phase 2 нужны (текущая задача охватывает только MariaDB):
- `bookva-mongo-data` — copy с `books-job-scheduler-mongo_data` через `cp -a` + `deleteMany({'data.idSeller': 2})` для cleanup чужих agenda jobs.
- `bookva-es-data` — пустой volume; данные через ES snapshot+restore из shared kzntsv ES в Phase 2.
- `bookva-minio-data` ИЛИ FS-rename `books``slovo` + `cp -a slovo bookva` в shared MinIO контейнере.
- Config volumes — `bookva-{api,scheduler,task-runner}-config` + `bookva-web-branding` + `bookva-ntfy-data` (см. README в bookva-overlay для bootstrap).
### Step 4 — bookva-db login-gate
После volume copy + bookva-db up:
```sql
-- В bookva-db только (slovo-db не трогать!):
UPDATE users SET password = SHA2(UUID(), 256) WHERE id NOT IN (1, 2);
```
(точная команда — после проверки актуального password storage format в `packages/api/server/services/auth/*` или embedded `packages/web/server/api/auth/login.post.js`.)
Эффект: только id=1 (Bookva-учредитель) + id=2 (Slovo-учредитель) могут логиниться в `bookseller.kzntsv.site`. Остальные user-записи остаются, но без работающего пароля.
### Step 5 — DNS
Добавить A-record `bookseller.kzntsv.site` → books-vds IP (тот же что `bookva.kzntsv.site` сейчас).
После cutover (отдельная задача) — `bookva.kzntsv.site` остаётся на тот же IP (роутит на slovo стек).
### Step 6 — PWA redirect handoff (за 1-2 недели до cutover)
В Portainer для текущего `books-web` stack (= future slovo-web после cutover) добавить env:
```
NUXT_PUBLIC_BOOKVA_REDIRECT_URL=https://bookseller.kzntsv.site
```
После DEPLOY_AT bump → recreate web container → новый SW bundle подхватывает `BookvaRedirectBanner` компонент. user.id=1 видит modal «Bookva теперь на bookseller.kzntsv.site → Перейти».
### Step 7 — books-web embedded-api env (pending step из embed-api handoff)
В Portainer `books-web` stack environment:
ADD:
```
NUXT_AUTH_TOKEN=<значение из books-api.NITRO_AUTH_TOKEN>
NUXT_JWT_SECRET_KEY=<значение из books-api.NITRO_JWT_SECRET_KEY>
```
CHANGE/REMOVE:
```
NUXT_PUBLIC_BOOKS_API_URL=/api # было https://books.kzntsv.site, переключаем на embedded
NUXT_PUBLIC_BOOKS_API_KEY= # удалить (legacy)
```
После DEPLOY_AT bump → recreate. Embedded api начнёт обслуживать `/api/*` (вместо старого books-api стека).
После 24-48ч стабильности — остановить `books-api` stack (отдельная micro-task).
## Acceptance
- Gitea secrets выставлены (Step 1).
- Portainer stacks `bookva-*` созданы (Step 2), stack IDs в Gitea secrets.
- Volumes `bookva-*` существуют + data populated (Step 3).
- bookva-db login-gate применён (Step 4).
- DNS `bookseller.kzntsv.site` → books-vds (Step 5).
- `NUXT_PUBLIC_BOOKVA_REDIRECT_URL` в books-web env (Step 6) — за 1-2 недели до cutover.
- books-web env обновлён для embedded api (Step 7).
- Acceptance не включает `compose up bookva-*` — это **отдельная** задача `bookva-tenant-cutover` (создаётся когда юзер скажет «давай поднимать Bookva»).
## Status
🟢 closed 2026-05-26 — 7/7 (Step 6 descoped) + extension: bookva-minio container + external port-bind для bookva-db/bookva-minio.
## Post-closure follow-up в той же сессии
- **bookva-minio container added** (user request — separate MinIO instance вместо bucket-split в shared MinIO). Image `minio:RELEASE.2020-07-13` (parity с books-minio), volume bookva-minio-data (1.2G — cp -a `/usr/docker/minio/data/books`), Portainer stack ID 49. Same MINIO creds для zero-config-change. Bookva apps reach через `http://bookva-minio:9000` (internal). bookva-overlay templates updated (s3.endpoint, bucket=books).
- **External access для bookva-db** + **bookva-minio**: port-bind на ALT портах (books-db занимает 3306, books-minio занимает 9000).
- `bookva-db``89.253.255.133:33306` (MariaDB). Verified — handshake `10.6.26-MariaDB-ubu2204` ✓.
- `bookva-minio``http://89.253.255.133:9001` (S3 API). Verified — 403 anon (sig required) = service alive ✓.
- Traefik HTTPS labels добавлены для `bookva-minio.kzntsv.site` (активируется когда DNS A-record добавлен в reg.ru).
- **Step 6 descoped** — PWA redirect modal для одного user'а (id=1, Bookva founder) = over-engineering. Substitute = manual heads-up учредителю Bookva перед cutover. Trade-off acceptable (краткое недоумение «где Bookva?» если откроет старый bookmark post-cutover).
## Closure note
Scope полностью выполнен per acceptance кроме Step 6 (delayed by design) и одной дискретной деференции (bookva-ozon-mcp stack — image отсутствует в registry, требует build в victor/books). Все остальные шаги closed:
- **Step 1** ✅ 7 Gitea secrets созданы (BOOKVA_OVERLAY_TOKEN scope=read:repository, HEALTHCHECK_BOOKVA_{API,WEB}_URL, PORTAINER_STACK_ID_BOOKVA_{API,WEB,SCHEDULER,OPS_MCP}).
- **Step 2** ✅ 9/10 Portainer stacks (ozon-mcp deferred). User-facing stopped pre-cutover (Status=2), infra (db/mongo/es) активны.
- **Step 3** ✅ 6 volumes + 4 populated из corrected overlay templates. bookva-db-data (4.7G), bookva-mongo-data (517M) cp -a с downtimes 59s + 5s. MinIO bucket copy deferred (cutover-time через `mc cp books bookva`).
- **Step 4** ✅ bookva-db login-gate `UPDATE users SET password=UUID() WHERE id_user NOT IN (1,2)` — 3 users (id≥3) scrambled, books-db verified untouched.
- **Step 5** ✅ DNS bookseller.kzntsv.site pre-existed (closed by inspection).
- **Step 6** 🔵 delayed — выставить `NUXT_PUBLIC_BOOKVA_REDIRECT_URL` за 1-2 нед до cutover.
- **Step 7** ✅ books-web embed-api live (Portainer stack 24 PUT + books `75ea320` push). bookva.kzntsv.site/api/* via embed. books-api stack fallback 24-48ч soak до stop micro-task'и.
Discovered + fixed cross-repo (bookva-overlay 4 commits, books 2 commits):
- bookva-overlay `5b173de` — config-templates align prod shape + design v3 hostname
- bookva-overlay `264373d` — .env.example design v3 hostnames
- bookva-overlay `23eb374` — db.compose.yml mariadb:10.11 → 10.6 (match books-db для cp -a)
- bookva-overlay `93c2ea2` — mongo.compose.yml mongo:7 → 4.2 (WiredTiger format compat)
- bookva-overlay `2b20a3e` — api.compose.yml remove traefik (internal-only per v3)
- bookva-overlay `8bffd68` — depends_on dropped (per-stack model)
- books `2f7b539` — secrets.GITEA_TOKEN → BOOKVA_OVERLAY_TOKEN (Gitea reserved prefix)
- books `75ea320` — deploy/web.compose.yml embed-api refs (NUXT_AUTH_TOKEN + NUXT_JWT_SECRET_KEY)
Workflow E2E verified: dispatch tenant=bookva, all 4 svc deploy gracefully — BOOKVA_OVERLAY_TOKEN clone bookva-overlay OK, скип per-svc если stack_id отсутствовал (до записи).
## Where I stopped
closed — handoff to `bookva-tenant-cutover` task (когда user скажет «давай поднимать Bookva»). Cutover task должна: re-start stopped Portainer stacks (api/web/scheduler/task-runner/ops-mcp/ntfy), `mc cp books bookva` MinIO bucket, ES snapshot+restore from shared kzntsv → bookva-es (Phase 2), smoke с двух сетей, books-api stack stop после 24-48ч soak post-Step-7.
## Decisions log
- **2026-05-26 — Step 5 closed by inspection.** `bookseller.kzntsv.site` уже резолвится authoritative `ns1.reg.ru``89.253.255.133`. User либо добавил A-record ранее, либо wildcard. Zero work needed.
- **2026-05-26 — Step 1: Gitea reserves `GITEA_*` prefix.** Попытка PUT `GITEA_TOKEN` secret вернула 400 "invalid variable or secret name". Создан новый PAT scope=`read:repository` для OpeItcLoc03 (UI generate, т.к. `POST /users/.../tokens` требует basic auth, не token). Token в pass `gitea/victor-books-ci-bookva-overlay`. Записан в victor/books secret под именем `BOOKVA_OVERLAY_TOKEN`. deploy.yml line 80 patched (`secrets.GITEA_TOKEN``secrets.BOOKVA_OVERLAY_TOKEN`) — commit `2f7b539` pushed.
- **2026-05-26 — bookva-overlay templates stale + missing fields.** При populate выяснилось что `config-templates/api/default.json` + `scheduler/default.json` короче prod (missing `jobs[]`, `jobs-new[]`, `docker`, `mailSettings`, `ntfy`), а `api.url`/`web.url` в scheduler template использовали `api.bookva.kzntsv.site`/`bookva.kzntsv.site` (старая нотация, до design v3 hostname swap). `task-runner/default.json` отсутствовал вовсе. Переписал из prod baseline + per-tenant overrides (`bookva-db`, `mongodb://bookva-mongo:27017/agendaDb`, `https://bookseller.kzntsv.site`, `s3.bucket=bookva`, `http://bookva-es:9200`). README hostname table → design v3. bookva-overlay commit `5b173de` pushed.
- **2026-05-26 — Step 7 atomic env+compose PUT.** books-web compose template references меняем legacy `NUXT_PUBLIC_BOOKS_API_KEY``NUXT_AUTH_TOKEN` + `NUXT_JWT_SECRET_KEY` (server-side, не публикуется в client bundle). Values из books-api container env (`NITRO_AUTH_TOKEN=81e123d0-...`, `NITRO_JWT_SECRET_KEY=505172e5...`). Portainer stack 24 updated via API (PUT compose+env atomically, container recreated, DEPLOY_AT bumped). Smoke: `/api/healthz=200`, `/api/auth/login.post=401 "JWT-required"` matches books-api behavior. books `75ea320` pushed. **Гонка race:** PUT в Portainer перед push в git, чтобы next CI auto-deploy не подхватил stale compose без NUXT_AUTH_TOKEN ref.
- **2026-05-26 — Step 3 scope split.** Этот task охватывает 6 volumes (api-config, scheduler-config, task-runner-config, web-branding, ntfy-data, es-data) + populate из corrected templates. **Stateful data volumes** (bookva-db-data via cp -a `books-db`, bookva-mongo-data via cp -a books-job-scheduler-mongo) = scope of separate `stateful-split-volume-copy` task (under maintenance window). MinIO rename (`books``slovo` + `cp slovo bookva`) = separate consideration, требует books-api reconfig (новая bucket).
## Completed steps
- [x] Step 5 — DNS `bookseller.kzntsv.site``89.253.255.133` (закрыто inspection, было pre-existing)
- [x] Step 1 — Gitea secret `BOOKVA_OVERLAY_TOKEN` создан в victor/books + deploy.yml `secrets.GITEA_TOKEN``secrets.BOOKVA_OVERLAY_TOKEN` (books `2f7b539` pushed). Token в pass `gitea/victor-books-ci-bookva-overlay`. Stack ID secrets отложены до Step 2.
- [x] Step 3 (config volumes part) — 6 empty external volumes созданы (`bookva-{api-config,scheduler-config,task-runner-config,web-branding,ntfy-data,es-data}`). 4 populated с corrected templates: `bookva-api-config`, `bookva-scheduler-config`, `bookva-task-runner-config`, `bookva-web-branding`. 2 intentionally empty: `bookva-ntfy-data` (чистый старт), `bookva-es-data` (Phase 2 snapshot/restore).
- [x] Bookva-overlay template correctness fix — api/scheduler/task-runner templates aligned to prod shape + design v3 (commit `5b173de` pushed).
- [x] Step 7 — books-web embed-api env switch live. Portainer stack 24: env={`NUXT_PUBLIC_BOOKS_API_URL=/api`, `NUXT_AUTH_TOKEN=81e123d0-...`, `NUXT_JWT_SECRET_KEY=505172e5...`}, compose template updated. Smoke green. books `75ea320` pushed. books-api stack продолжает работать как fallback — отдельной micro-task'ой stop после 24-48ч soak.
## Remaining (maintenance-window or delayed)
- **Step 2 — Portainer bookva-* stacks (10 stacks).** Требует все volumes existing (включая stateful). Запускается после `stateful-split-volume-copy` task. Env stack-level: `CORE_SHA=master-<latest>`, `HOSTNAME_API=HOSTNAME_WEB=bookseller.kzntsv.site`, `HOSTNAME_NTFY=push.bookseller.kzntsv.site`.
- **Step 3 remaining — Mongo cp + MariaDB cp + MinIO rename.** Mongo + MariaDB → блок stateful-split-volume-copy. MinIO rename — disruptive (books-api config change нужен), пока defer; alternative — copy `books/*` объекты в new `bookva/*` bucket без rename (write-only path для bookva-API).
- **Step 1 finish — записать `PORTAINER_STACK_ID_BOOKVA_{API,WEB,SCHEDULER,OPS_MCP}` после Step 2.** Без них deploy.yml skip'ает bookva tenant с warning (graceful).
- **Step 4 — bookva-db login-gate SQL.** `UPDATE users SET password=SHA2(UUID(),256) WHERE id NOT IN (1,2)` против bookva-db. Точную команду подтвердить по `packages/api/server/services/auth/*` (password storage format в prod).
- **Step 6 — PWA redirect handoff (delayed).** Per spec — за 1-2 нед до cutover bump `NUXT_PUBLIC_BOOKVA_REDIRECT_URL` env в books-web (= future slovo-web) → SW bundle подхватит `BookvaRedirectBanner` модал для user.id=1.
## Next action
Coordinate с user'ом 1-2h maintenance window slot — execute `stateful-split-volume-copy` playbook (bookva-db + bookva-mongo data via `cp -a`), затем Step 2 Portainer stacks create (через Portainer API с volumes уже existing), затем Step 1 finish (stack IDs → Gitea secrets), затем Step 4 login-gate. Step 6 — отдельный заход за 1-2 нед до cutover.
## Blocker
`stateful-split-volume-copy` task — не started. Без MariaDB+Mongo volumes — Step 2 stacks deploy fail (volume not found). Maintenance-window window needs user signoff.
## Branch
master
<!-- created-by: vitya / 2026-05-26 / Phase 2 cutover prep после tenant-split code-side done -->
<!-- progress-2026-05-26: Steps 1, 3-partial, 5, 7 closed; bookva-overlay templates fixed + pushed; victor/books deploy.yml + web.compose.yml fixed + pushed -->

View File

@@ -0,0 +1,85 @@
# modulair-rag-vds-redeploy
## Goal
Re-deploy the lost `modulair-rag` stack (4 containers) from the dead Synology NAS onto the VDS (`89.253.255.94`, rusonyx). Fresh start — knowledge base + pipeline cache were on lost named volumes, not critical (deploy was only ~1 week old). This `.admin` project owns the ops; `modulair-rag` repo stays the consumer.
## Source of truth (read before acting)
- **Full context-freeze (canonical):** `~/projects/modulair-rag/.wiki/concepts/nas-loss-vds-redeploy-context.md` — pre-flight, deploy steps, acceptance, post-deploy. *(Note: currently uncommitted in the modulair-rag repo, but present on disk.)*
- **Compose file (deploy as-is):** `~/projects/modulair-rag/compose.yml` — 4 services, named volumes, `proxy` external network. Do not edit; paste into Portainer.
- **Design spec (architecture):** `mcp__projects-meta__knowledge_get slug="concepts/modulair-rag-design"` (shared wiki).
- **Stack identity (NAS-era, now stale):** `~/projects/modulair-rag/.wiki/entities/portainer-stack.md`.
## VDS state — VERIFIED 2026-05-27 (via vds-ops `ops_docker_ps` / `ops_docker_inspect`)
-**MinIO** container `minio/minio:latest` running 4d on VDS — the original blocker is CLEARED.
-**postgres:16** running; on networks `proxy` (172.18.x) **and** `shared-dbs`, aliases `postgres`. → hostname `postgres:5432` from `modulair-pipeline`/`modulair-mcp` (which sit on `proxy`) **will resolve**. Compose's cross-stack DB reference is sound, no extra network wiring needed.
- Minor: postgres runs with `ssl=on`; libpq default `sslmode=prefer` negotiates automatically — not a blocker.
-`registry:2.8.3` (`registry.kzntsv.site`) running — images `modulair-pipeline:latest`, `tier1-converter:latest`, `modulair-mcp:latest` were pushed 2026-05-13 (per context-freeze). Verify they're still present before deploy.
-`traefik:v2.11` running — compose labels target `modulair-mcp.kzntsv.site`.
- ⛔ None of the 4 modulair containers exist yet (`lightrag-modulair`, `modulair-pipeline`, `tier1-converter`, `modulair-mcp`) — clean deploy.
## Pre-flight — admin must verify/create (NOT visible to read-only vds-ops)
1. [ ] **MinIO bucket** `modulair-rag` exists. MinIO container is up but bucket membership is internal — create if missing (`mc mb`).
2. [ ] **Postgres DB** `modulair_rag` exists → `CREATE DATABASE modulair_rag;` if not. lightrag self-creates its schema on first start — do NOT pre-init tables.
3. [ ] **DNS** `modulair-mcp.kzntsv.site` → VDS IP `89.253.255.94` (likely still points at the dead NAS — repoint via regru/cloudflare).
4. [ ] **MinIO endpoint sanity:** compose env uses `MINIO_ENDPOINT=minio.kzntsv.site:443` (SSL, hairpin via traefik back to the VDS minio). Confirm traefik route `minio.kzntsv.site` now points at the VDS minio container, and DNS resolves to VDS. (Alternative: direct `minio:9000` on a shared network if both end up on `proxy` — only if the external route is problematic.)
5. [ ] **Secrets via pass** (pattern from `[secrets-manager-adopt]`):
```
pass insert modulair-rag/routerai-key
pass insert modulair-rag/ollama-api-key
pass insert modulair-rag/minio-access-key
pass insert modulair-rag/minio-secret-key
pass insert modulair-rag/postgres-password
```
## Deploy (Portainer — per `.admin` VDS-ops rule, NOT ssh+compose)
1. Open Portainer `https://portainer.vds.kzntsv.site`.
2. New Stack → paste `~/projects/modulair-rag/compose.yml` as-is.
3. Environment vars — fill from `pass show` + statics:
- `ROUTERAI_API_KEY` (pass), `ROUTERAI_BASE_URL=https://routerai.ru/api/v1`, `ROUTERAI_EMBED_MODEL=qwen3-embedding-8b`
- `OLLAMA_API_KEY` (pass), `OLLAMA_HOST=https://ollama.com`, `OLLAMA_LLM_MODEL=qwen3-next:latest`
- `MINIO_ENDPOINT=minio.kzntsv.site`, `MINIO_PORT=443`, `MINIO_USE_SSL=true`, `MINIO_FORCE_PATH_STYLE=true`, `MINIO_BUCKET=modulair-rag`, `MINIO_REGION=us-east-1`, `MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` (pass)
- `POSTGRES_USER`, `POSTGRES_PASSWORD` (pass), `POSTGRES_DB=modulair_rag`
- `IMAGE_TAG=latest`
4. Pre-pull images, Deploy.
## Acceptance
- [ ] 4 containers up, no restart-loops (`ops_docker_ps` on VDS).
- [ ] `lightrag-modulair :9621` healthcheck responds (internal network, not exposed).
- [ ] `https://modulair-mcp.kzntsv.site` → 200 on root.
- [ ] `modulair-pipeline` + `tier1-converter` NOT in CPU-spin — fix `[pipeline-tier1-cpu-fix]` already in `:latest` (commits `24c9bd5` + `ceb525e`).
- [ ] `modulair-pipeline` log: no `connection refused` to postgres.
- [ ] pipeline log: no `NoSuchBucket` / `AccessDenied` from MinIO.
## Post-deploy
1. Rewrite `~/projects/modulair-rag/.wiki/entities/portainer-stack.md` for VDS (Docker host, Portainer URL, MinIO endpoint).
2. Close this 🟢 task with note.
3. `[future-resilient-architecture-goals]` — add modulair-rag to RTO/RPO matrix (low priority, non-client prod, but host-loss recovery should be documented).
## Decisions log
- 2026-05-27 (exec session, .admin): pre-flight verify вскрыл, что таска писалась по stale-предпосылкам. Исправления:
- **Registry образы ОТСУТСТВУЮТ** (open-q #1 = NO). Registry на VDS переустановлен с нуля 2026-05-20 (`pass vds-kzntsv/full-env` коммент: "не мигрировали с kreknin — user accepts loss"). Образы push'ились 2026-05-13 на kreknin-registry → потеряны. Каталог `registry.kzntsv.site/v2/_catalog`: только books/board/vds-ops. → **rebuild всех 3 обязателен.**
- **MINIO_ENDPOINT исправлен** (open-q #2): `minio.kzntsv.site` → 89.253.255.**133** (books VDS, чужой!). Правильный route на этом VDS = `minio.vds.kzntsv.site` (traefik label на minio-контейнере = `Host(minio.vds.kzntsv.site)`, health/live=200). tier1-converter сидит только на `modulair` bridge (НЕ на `proxy`) → внутренний `minio:9000` для него не резолвится → нужен внешний route для всех. Итог env: `MINIO_ENDPOINT=minio.vds.kzntsv.site`, PORT=443, SSL=true. Compose не редактирую (paste-as-is), это env-var.
- **MinIO creds:** создан scoped svcacct `modulair-rag-svc` (политика — только bucket `modulair-rag`, verified видит только свой bucket). Не root. → `pass modulair-rag/minio-{access,secret}-key`.
- **MinIO bucket** `modulair-rag` создан (mc, локальный alias на minio.vds.kzntsv.site root creds).
- **Postgres DB** `modulair_rag` создана на `postgres.vds.kzntsv.site:5432` (docker one-off psql, sslmode=require). POSTGRES_USER=postgres (shared), pwd из `pass vds-kzntsv/full-env`.
- **DNS** `modulair-mcp.kzntsv.site` → был 94.19.247.14 (мёртвый NAS = домашний IP). User поправил → 89.253.255.94 (verified public+authoritative).
- **ROUTERAI key** не было в pass — user дал, сохранён `pass modulair-rag/routerai-key`. OLLAMA key = `pass interns/ollama-cloud-api-key`.
- **Portainer API key протух** (401) → auth через admin user/pass (`pass vds-kzntsv/full-env`), endpoint=1 (local).
- **BUILD-БЛОКЕР: tier1-converter = 10.2GB (3.36GB сжатый).** Push с локальной машины падал HTTP 499 (home-канал/middlebox рвёт многогиг HTTPS-заливку через traefik; docker push монолитный, без resume). → **Решение: сборка НА VDS** (`ssh vitya@89.253.255.94` ключ `~/.ssh/id_ed25519`, vitya в docker-группе; клон приватного репо из локального gitea `git.kzntsv.site/cancel_music/modulair-rag` юзером OpeItcLoc03 + admin-token; push registry локально). services/ на origin/master идентичен локальному HEAD (cpu-fix ceb525e внутри). IMAGE_TAG=0.1.0.
- 2026-05-27 (DEPLOY ✅): стек `modulair-rag` (Portainer stack id=15, endpoint=1) развёрнут. Образы 0.1.0 (pipeline/tier1-converter/modulair-mcp) собраны на VDS + lightrag ghcr.io/hkuds/lightrag:latest. Acceptance 6/6 green: 4 контейнера up без restart-loop; lightrag Uvicorn :9621 startup complete; https://modulair-mcp.kzntsv.site → 200 (uvicorn, /docs тоже 200); pipeline+tier1 0% CPU (cpu-fix ok); pipeline-лог без postgres/minio ошибок.
- **+1 compose-фикс при деплое:** лейбл `traefik...modulair-mcp.entrypoints=https` — NAS-имя; на этом VDS entrypoints = `web`/`websecure` (читал `/opt/stacks/traefik/data/traefik.yml`). Без фикса traefik default-404. Поправлено в live-стеке (PUT, https→websecure). **TODO в репо modulair-rag/compose.yml** — закоммитить тот же фикс, иначе следующий redeploy снова 404.
## Post-deploy / follow-ups
- [x] **modulair-rag/compose.yml** — `entrypoints=https`→`websecure` (commit `fc68a16`). Параллельная сессия завершилась (tree clean), сделал сам.
- [x] **modulair-rag/.wiki/entities/portainer-stack.md** — переписана под VDS (stack 15, websecure, minio.vds, build-on-VDS workflow, model-config table) + log.md entry (`fc68a16`).
- [x] **#3 lightrag embedding — RESOLVED & verified end-to-end.** Был latent-баг (с NAS): stock-образ LightRAG читает `LLM_*`/`EMBEDDING_*`, а не `ROUTERAI_*`/`OLLAMA_*` → дефолты (`binding=ollama model=None dim=1024`). Замаплены stack-inputs на нативные имена в compose: `EMBEDDING_BINDING=openai` (RouterAI qwen/qwen3-embedding-8b, **DIM=4096**), `LLM_BINDING=ollama` (Ollama Cloud). LLM-тег `qwen3-next:latest` → 404 на Ollama Cloud → исправлен на **`qwen3-next:80b-cloud`** (user-confirmed; `qwen3-next:80b` тоже валиден). Live-smoke на VDS: insert → embedding(RouterAI)+extraction(Ollama) → `4 entities, 3 relations`, потом workspace очищен. `compose.yml`+`lightrag-models.md` (`fc68a16`).
- [ ] `[future-resilient-architecture-goals]` — добавить modulair-rag в RTO/RPO matrix (low prio).
- [ ] **PUSH:** `modulair-rag fc68a16` + `.admin` task-коммиты unpushed (автопуш не выдан в этой сессии).
## Open questions
- [x] Registry images present? → NO, rebuilt on VDS (см. Decisions).
- [x] minio.kzntsv.site repointed? → NO, это books VDS; используем `minio.vds.kzntsv.site`.
**Branch:** master
<!-- created-by: vitya@modulair-rag-session / 2026-05-27 / from: modulair-rag nas-loss-vds-redeploy-context concept; user handed deploy to .admin (has VDS access) -->

View File

@@ -0,0 +1,131 @@
# restore-elasticsearch-indices-books-vds
## Goal
Восстановить 3 ES индекса (`epz`, `products`, `artmone`) на canonical endpoint `elasticsearch.kzntsv.site` (books VDS, Portainer stack 33). Сейчас target ES пустой → весь поиск в books-app (slovo) сломан (404 `index_not_found_exception` для `epz` и `products`).
## Симптомы (зафиксировано 2026-05-28 ~15:50 MSK)
- `bookva.kzntsv.site/api/products/search?q=...` → 500 `no such index [products]`.
- `bookva.kzntsv.site/api/epz/search?q=...` → 500 `no such index [epz]`.
- `books-api` + `books-web` логи (stderr) — десятки `ResponseError: index_not_found_exception` за час.
- User-facing impact: страница `/epz/search` и поиск товаров в slovo UI не работают.
## Closure note (2026-05-28 ~20:10 MSK)
🟢 **Восстановлено за 46 сек через snapshot restore** + два preventive фикса в один заход.
### Restore
```
POST /_snapshot/kreknin/daily-2026-05-25/_restore?wait_for_completion=true
{"indices":"epz,products,artmone","include_global_state":false,"include_aliases":true,"index_settings":{"number_of_replicas":0}}
```
- artmone: 2621 docs ✓ (source: 2621)
- epz: 820604 docs ✓ (source: 820604)
- products: 105922 docs ✓ (source: 105922)
- cluster: green
- 3 consumers (books-api/task-runner/job-scheduler) рестартованы — 0 `index_not_found_exception` в логах за 5 мин
- public smoke `bookva.kzntsv.site/api/{epz,products}/search` → 401 от auth middleware (поиск-pipeline жив, до wipe было 500)
**Ключевая удача:** в исходной задаче было записано «`pre-migration-2026-05-25` содержит только `read_me` placeholder — не годится для восстановления» — это правда, но это был **другой** snapshot (он был _до_ reindex'а). А `daily-2026-05-25` (сделан кроном `books-vds-backup-daily-kreknin` в 09:51 UTC через 2ч _после_ reindex'а) содержал full corpus — `[read_me, products, epz, artmone, .tasks]`, 25 sec duration, 5 шардов SUCCESS. Snapshot pipeline (стояший с 25.05) автоматически создал safety net.
### Root cause (forensics из ES container log)
Container `elasticsearch` `restart_count=0, created=2026-05-25T07:51:38`**никогда не рестартовал**, volume bind не задет, индексы существовали и были **удалены через ES API**:
```
2026-05-26 10:21:43.526 UTC [read_me/IfM2V1pTQk-...] deleting index
2026-05-26 10:21:44.075 UTC [artmone/xQdFWkafSKy-...] deleting index
2026-05-26 10:21:44.257 UTC [epz/AV8inT4YS-e-...] deleting index
2026-05-26 10:21:44.450 UTC [.tasks/EbutxIzdTPSq-...] deleting index
2026-05-26 10:21:44.576 UTC [products/6VRXsuP5QV-...] deleting index
```
5 индексов (включая system `.tasks`) удалены за 1 секунду = `DELETE _all` / `DELETE *` / Kibana DevTools «delete index».
Timeline:
- **2026-05-25 09:51 UTC** — daily snapshot SUCCESS [read_me, products, epz, artmone, .tasks] (full data)
- **между ~10:00 и 26.05 03:02 UTC** — Wipe #1 (snapshot 26.05 содержит только [read_me])
- **2026-05-26 05:4906:44 UTC** — re-reindex (artmone+epz+products, full counts восстановлены)
- **2026-05-26 09:10:18 UTC** — `bookva-es` container created (cutover-prep Step 2)
- **2026-05-26 10:21:43 UTC** — Wipe #2 (1ч 11мин после создания bookva-es)
`bookva-es` использует named volume `bookva-es-data`, БЕЗ traefik labels (internal-only). Не задел canonical через mount или routing. **Best guess:** оператор в момент cutover-prep хотел очистить новый `bookva-es:9200`, но команда ушла на `elasticsearch.kzntsv.site` (canonical, slovo). Bookva-es internal-only → DELETE с воркстейшна туда не дойти; canonical через traefik basicAuth → доходит.
**Caller identity unrecoverable:** ES audit log = X-Pack платная фича (нет на free 7.10). Traefik accessLog был отключён (нет секции `accessLog:` в `traefik.yml`). Portainer audit = CE без enterprise. Только timestamp + DELETE fact в ES log.
### Preventive fixes (applied 2026-05-28 ~20:10 MSK)
**Fix #1: `action.destructive_requires_name=true`** на stack 33 (env var добавлен через Portainer PUT API). Verified:
- `DELETE /_all` → 400 «Wildcard expressions or all indices are not allowed»
- `DELETE /ep*` → 400 same
- `DELETE /probe-canary` (by exact name) → 200 (легитимные операции работают)
Pattern удаления который случился (5 индексов в 1 сек) теперь **физически невозможен**.
**Fix #2: traefik accessLog** в JSON формат, `/letsencrypt/access.log` (bind-mounted, persistent). Backup `traefik.yml.bak-pre-accesslog-2026-05-28` рядом. Verified entries содержат `RequestMethod`, `RequestHost`, `RequestPath`, `ClientHost`, `ClientUsername`, `DownstreamStatus`, `TLSCipher`. Будущие `DELETE` запросы оставят след — даже если кто-то снова попадёт не на тот endpoint, retrospective forensics возможен.
### Artifacts
- `.scratch/stack33-put-2026-05-28.json` — Portainer PUT payload (новый env + сохранён `reindex.remote.whitelist`).
- `.wiki/concepts/es-destructive-delete-incident-2026-05-26.md` — incident concept (написан в эту сессию).
- ES container env post-fix: `reindex.remote.whitelist=elasticold.kzntsv.site:443` + `action.destructive_requires_name=true`.
- Traefik static config: `accessLog: { filePath: /letsencrypt/access.log, format: json, bufferingSize: 100 }`.
### Follow-ups (не блокеры)
- **Logrotate на `access.log`** — при ~1 req/sec будет ~3050 MB/day. Disk free 63G, ressedimption через 12 года максимум — но добавить logrotate стоит. Отдельная micro-task.
- **Переезд access.log из `/letsencrypt/`** в отдельный bind (`/usr/docker/traefik/logs/`) — косметика, текущее место persistent и работает.
- **Pass entry update** — комментарий в `books-vds/full-env` устарел: «NOT Portainer-managed (legacy SSH-compose): **elasticsearch**, mongo, minio, books-db…». Migration 25.05 (`books-vds-stacks-to-portainer`) уже мигрировала их. Поправить при следующем pass-edit.
## Текущее состояние ES endpoint'ов
**Source (Windows host, rollback — не выключен):** `https://elasticold.kzntsv.site/`
```
index health docs.count store.size
artmone yellow 2621 1.4mb
epz yellow 820604 699.7mb
products yellow 105922 26.6mb
```
**Target (books VDS, Portainer stack 33, canonical):** `https://elasticsearch.kzntsv.site/` — restored 2026-05-28, counts == source, green.
## ⛔ RECURRENCE 2026-05-29 — RCA был НЕВЕРНЫМ, дыра не закрыта
Через ~3ч после вчерашнего restore индексы снова исчезли (`artmone`/`epz`/`products`
удалены 28.05 20:13 UTC, остался ransom-`read_me`). Вчерашний best-guess «оператор в
cutover» **опровергнут**.
**True root cause:** stack 33 публиковал `ports: - 9200:9200` → docker прокинул
`0.0.0.0:9200` на публичный IP в обход traefik+firewalld. ES 7.10 free **без auth**
`curl http://89.253.255.133:9200/` без кредов возвращал cluster info. **Ransom-бот**
сканил порт, удалял индексы by-name (мимо вчерашнего Fix #1, который ловит только
`_all`/`*`), оставлял `read_me` с требованием 0.0041 BTC. accessLog (Fix #2) пуст по
DELETE — бот шёл прямо в `:9200`, не через traefik. bookva не задет (bookva-es порт не
публикует).
**Fix #3 (applied 2026-05-29):** убран `ports:` блок из stack 33 (Portainer PUT
`/api/stacks/33?endpointId=1`). Порт больше не публикуется (`docker ps``9200/tcp` без
`0.0.0.0:`, внешний `curl :9200` refused), traefik route жив. Затем `DELETE /read_me` +
restore `epz,products,artmone` из `daily-2026-05-25` (counts 820604/105922/2621, green) +
restart `books-api books-task-runner books-job-scheduler`. Поиск slovo через traefik
отдаёт хиты. Полный разбор: `.wiki/concepts/es-destructive-delete-incident-2026-05-26.md`
§ «Рецидив 2026-05-29».
**Spawned follow-up:** `harden-books-vds-exposed-ports` — exposure-audit нашёл ещё 5
сервисов с публичными host-портами (mongo/books-db/bookva-db/minio/bookva-minio, все
credentialed) + rsync:873. Не emergency, но закрыть.
## Branch
n/a (admin ops)
## Status
closed 2026-05-28 → **reopened+reclosed 2026-05-29** (true RCA: exposed port, Fix #3 applied)
<!-- created-by: vitya@books-session / 2026-05-28 / trigger: prod slovo поиск товаров+EPZ сломан, ES indices пусты на canonical endpoint -->
<!-- closed-by: vitya@.admin-exec / 2026-05-28 / snapshot restore + 2 preventive fixes + wiki concept ingest -->

View File

@@ -0,0 +1,96 @@
# vehicles-loader-progress-deploy
## Goal
Доставить `@stostayer/vehicles-loader` **0.4.0** (прогресс-логирование) на прод клиента и
верифицировать, что `journalctl` во время боевого `sync` показывает движение, а не ~2ч тишины.
Код-сайд готов: progress-logging зашипан в `stostayer.new` master (commit `a74ef73`, bump
0.3.0 → 0.4.0, 24/24 теста зелёные). Это чисто ops-handoff: rebuild образа на той же
схеме, что и 0.3.0 (build здесь → push в registry клиента `docker.stostayer.ru` → host
pull + re-tag), затем дождаться/прогнать sync и снять лог.
Закрывает единственный не-верифицированный acceptance-критерий исходной таски
`stostayer.new/.tasks/vehicles-loader-progress-logging.md` — «journalctl показывает движение»
(остальные 4/5 покрыты unit-тестами; этот по природе требует живого прод-прогона).
## Что нового в 0.4.0 (что должно появиться в логе)
- Фазовые строки: `▶ <phase> — start` / `✓ <phase> — done: N rows, <dur>` для
branches / vehicles / units / delete-sweep.
- Батч-прогресс: ` manufacturers: 18/183 (12s)`, ` units: 5/26 (…)` — авто-шаг ≈ total/10.
- delete-sweep per-model: ` delete-sweep <model>: N disabled` / `0 — выгрузка полная` /
`skip (пустой seen-set)`.
## Pending ops actions
- [x] **Build & push** на dev-машине из корня `stostayer.new` (2026-05-30): образ
`docker.stostayer.ru/vehicles-loader:0.4.0` собран (811MB, digest
`sha256:f2e10b1f090ba47f9b5de83b84f91c5ce827ecaf1e94bc0331f2d6773f85845a`),
`docker login` BA-кредами → `docker push` (общие слои с 0.3.0, докинуты только app-слои).
verdaccio-депы из `.yarn/cache` запеклись при build.
- [x] **На хосте клиента** (ssh `victor@new.stostayer.ru:20435`, docker через `sudo -S`):
`docker pull …:0.4.0` (digest совпал) + `docker tag …:0.4.0 vehicles-loader:latest`.
`:latest` теперь `0f8a4dd46236` = 0.4.0; 0.3.0 (`5407c0563e44`) оставлен под rollback.
- [x] Плановый прогон отстрелял **Sun 2026-05-31 06:21:48 → 07:53:14 MSK** на changed-выгрузке
(не skipped — 1С перезаписала файл в 6:00, checksum разошёлся).
- [x] **Live-verify выполнен (2026-05-31):** журнал показал движение по всем фазам —
`▶ branches/vehicles/units/delete-sweep — start`, батч-прогресс
(`manufacturers: 18/183 … 183/183`, `units: 3/26 … 26/26`), `✓ … done: N rows`,
per-model `delete-sweep … 0 — выгрузка полная`. **НЕ тишина.** `Finished … Deactivated
successfully` = exit 0. `importRun id=4 ok`, `reportJson errors:[]`.
## Acceptance criteria
- Образ `docker.stostayer.ru/vehicles-loader:0.4.0` собран здесь и доступен на хосте клиента,
`:latest` указывает на 0.4.0.
- В `journalctl -u vehicles-loader.service` боевого прогона видно движение по фазам +
батч-прогресс + per-model delete-sweep counts (не тишина после трёх `Total …`).
- Данные залились корректно (`importRun` новый `ok`-ряд), email-отчёт ушёл (попутно
закрывает остаток «живой email-SEND в составе sync» из `vehicles-loader-image-distribution`).
## Decisions log
- 2026-06-01: **прод-подтверждение за 01.06 (опц. follow-up из хендоффа закрыт).** Плановый прогон
06:20:45→07:55:22 MSK, `importRun id=5 status=ok`, `errorMessage=NULL`, exit 0. Progress-logging
показал движение по всем фазам (batch `units 26/26`, per-model delete-sweep `0 — выгрузка полная`).
Counts: vehicles 3884, units 100529, `service updated:103`. **Email реально дошёл до gmail** (user
подтвердил «письмо пришло») — фикс адресата отработал на боевом прогоне, не только на тест-письме.
`generation unmatched:51` стабилен (== id=4 за 31.05) — не регрессия, остаётся follow-up'ом.
- 2026-05-31: **CLOSED 🟢.** Live-verify прогона 06:21→07:53 MSK прошёл (см. Pending ops actions).
Progress-logging работает как задумано. units-фаза = 1h 31m на 100529 rows (узкое место,
кандидат на оптимизацию — follow-up, не блокер). generation `unmatched:51` — глянуть отдельно.
- 2026-05-31: **email-баг найден и починен.** 4-й acceptance («email ушёл») валился молча:
`STOSTAYER_MAIL_TO=site@stostayer.ru` в `/etc/stostayer/vehicles-loader.env` — отчёт слался
сам себе, а не user'у. Отправка отрабатывала успешно (потому прогон и `ok`), адресат неверный.
Фикс: `STOSTAYER_MAIL_TO=vitya.kuznetsov@gmail.com` (бэкап `vehicles-loader.env.bak.20260531`),
`FROM=site@stostayer.ru` без изменений (это и есть SMTP-аккаунт релея `mail.stostayer.ru`).
Доставка подтверждена тест-письмом из контейнера: `ACCEPTED=[gmail]`, `250 queued as 8732C122F18`.
Хвост: в репо `config/default.json` дефолт `to` всё ещё `site@stostayer.ru` (прод перекрыт env) —
опц. выровнять в `stostayer.new`.
- 2026-05-30: **build+push+host-deploy выполнены.** Образ 0.4.0 собран здесь, запушен в
`docker.stostayer.ru`, на хосте pull + re-tag `:latest` → 0.4.0. 0.3.0 retained для rollback.
- 2026-05-30: open question #1 (push кода) снят — `a74ef73` оказался **уже в origin/master**
(`git branch -r --contains a74ef73` → origin/master), дерево чистое. Билдил из чистого дерева,
Rule-4-вопроса нет.
- 2026-05-30: open question #2 (verify-окно) решён user'ом = **ждём natural 06:20** (Sun 31.05),
НЕ форсим baseline-reset. Причина: форс = внеплановое email-письмо клиенту + спурьёзный
ре-импорт; natural-прогон и так = боевой acceptance. Trade-off: verify unattended, в след. сессии.
- 2026-05-30: заведено из `stostayer.new` session по явному указанию (закрыть код-таску
by-inspection, live-verify вынести в .admin как deploy-follow-up).
## Open questions
- [x] ~~Push `stostayer.new` master нужен до build?~~ — нет, `a74ef73` уже в origin/master.
- [x] ~~Где взять changed-выгрузку?~~ — ждём natural 06:21 Sun 31.05 (1С перезапишет файл в 6:00).
## Notes
- Схема деплоя 0.3.0 + обе инфра-гочи (IPv4 DB host; custom `/etc/hosts` для mail-DNS) —
`stostayer.new/.wiki/concepts/client-infra-access.md` + `vehicles-loader-docker-deploy.md`.
- Источник: `stostayer.new/.tasks/vehicles-loader-progress-logging.md` (🟢 closed by-inspection),
commit `a74ef73`.
- Предшественник: `.admin vehicles-loader-image-distribution` (🟢 closed 2026-05-29) — канал
поставки + первый боевой прогон 0.3.0.
<!-- created-by: vitya@stostayer.new-session / 2026-05-30 / handoff: stostayer.new/.tasks/vehicles-loader-progress-logging.md -->

View File

@@ -0,0 +1,50 @@
# [morethencms-s3-filestorage-provider] — S3-провайдер FileStorage для MoreThenCms (MinIO)
**Status:** 🟢 LIVE на прод RUVDS (2026-07-03). Split-brain закрыт: боевой catch-all `C:\sites\snolla` теперь читает/пишет ассеты/темы/галереи из MinIO. Локальный IIS (windows-recovery-host) — отложен до ухода сайтов с RUVDS (решение user).
### Прод-cutover RUVDS (2026-07-03)
- Backup: `web.config.bak-pre-s3-2026-07-03` + IIS-снапшот `pre-s3-cutover-2026-07-03`. Rollback = restore + recycle (Local вернётся, MinIO не трогается).
- 3 DLL (S3+AWSSDK.*) → `C:\sites\snolla\bin`. web.config `<fileStorageClients>`: 6 контентных классов (assets/galleries/images/scripts/stylesheets/watermarks) → S3, креды в конфиге (ACL SYSTEM+Admins); кэши (imageCache/uploadCache/contentCache/_imageCache-Azure) оставлены Local. Правка — точечный regex, UTF-8/BOM сохранён, XML валиден (10 записей).
- Pre-flight: RUVDS→minio.kzntsv.site:443 = TCP+HTTPS 200. Egress ок.
- Smoke GREEN: 7 тенантов 200/301 (0× 500); S3-read вживую — theme CSS `16ba5cb8…/css/lato.css` → 200/9239b/text/css, byte-parity с MinIO. Write-механика — self-test цикл (ранее).
- imageCache-прун бакета `themes` (2.1 ГБ мусора) — фоном bfb8wy94n.
**Валидация (standalone-проба, IIS/elevation не нужны):** Read/key/byte-parity ✅ + full self-test `put→exists→download(MD5==)→delete→exists=false` ✅ после upload-фикса. Upload-фикс: **`UseChunkEncoding=false`** в `PutObjectRequest` (MinIO отвергает AWSSDK 3.7 aws-chunked `STREAMING-AWS4-HMAC-SHA256-PAYLOAD`; `DisablePayloadSigning` НЕ помогает). 17/17 юнитов + интеграция. `_selftest/`-мусора нет.
### Метод валидации (важно — IIS/elevation НЕ нужны)
Провайдер чистый (AWSSDK + базовый FileStorageClient, без SnollaHost/Autofac) → грузится standalone в PowerShell: загрузить 3 DLL из `deploy/` + зависимости из `C:\sites\stostayer.old\bin` (резолвер с guard от рекурсии), инстанцировать `AssetsStorage` через `$type::new($cfgDict)`, гонять против real MinIO. Проба: scratchpad `s3-provider-probe.ps1` + `s3-upload-diag.ps1`. stostayer.old НЕ трогается (только его DLL читаются в чужой процесс). Self-test пишет в throwaway `assets/_selftest/` (чистится).
**Owner-split:** код — прогер (проект MoreThenCms); координация + deploy + приёмка — admin (я).
### Ключ-конвенция (ground-truthed 2026-07-03, финал)
Провайдер = калька с **Local** (не Azure — Azure в бою не гонялся), ключи по snolla-фронту `packages/core/lib/services/storage.js` (`key = ownerId.toLowerCase()+'/'+storageFilename`).
- **assets**: bucket `assets`, ключ `<ownerId:N>/<file>` — ownerId **без дефисов** (админка отдаёт `ToString("N")`, объекты в MinIO без дефисов). НЕ вставлять дефисы.
- **themes** (css/images/**js**/watermarks): единый bucket `themes`, ключ `<themeId:N>/{css,images,js,watermarks}/<file>`. scripts=`js` (не `/scripts`!). watermarks НЕ отдельный бакет — они в `App_Data\themes\<themeId>\watermarks\` (Local `WatermarksStorage`), 11 файлов вкл. tandemmebel.ru.
- **galleries**: bucket `galleries`, ключ `<siteId:N>/<file>` **плоско** (без `/images`).
- Azure separator-баг (`prefix+fileUri` без `/`) прогер поймал → `Trim('/')+"/"+fileUri`.
- Configuration net461→net462 (`OpeItcLoc03/MoreThenCms.Configuration 3e79ff2`) — build-only, на прод НЕ едет (шипим только 3 DLL).
- Приёмка на локальном IIS: **key-parity + byte-parity** sweep (ключ совпал с существующими + MD5==).
## Зачем
Админка MoreThenCms пишет ассеты на локальный диск IIS (`App_Data`, провайдер `FileStorage.Local`), а боевой фронт snolla читает из MinIO → split-brain (правка в админке не видна на сайте). Плюс отдельный симптом — 500 на delete из-за RX-only ACL (пофикшен 2026-07-03 выдачей Modify, но это лечит только delete, не устраняет раздвоение). Настоящее закрытие — посадить админку на тот же MinIO через S3-провайдер, которого в кодовой базе нет (есть только `Local` и `Azure`).
## Решения (приняты)
- Новая сборка `MoreThenCms.FileStorage.S3` — калька с `MoreThenCms.FileStorage.Azure`, backend `AWSSDK.S3` против MinIO (`ForcePathStyle=true`).
- Объём: **все** контентные классы (Assets/Galleries/Images/Scripts/Stylesheets/Watermarks). Кэши (image/upload/content) остаются Local.
- Один MinIO на всех: `minio.kzntsv.site` (books-vds, 89.253.255.133), path-style. Bucket = имя класса, ключ = `<prefix>/<file>` **без ведущего слэша** (parity с уже мигрированными объектами).
- Deploy target админки: локальный IIS на **windows-recovery-host** (репрпоуз PC; серверная роль была декоммишнута, сам PC жив). DNS-аудит 2026-07-03: инфра-хосты `*.kzntsv.site` резолвятся в реальные IP, перехвата `127.0.0.1` НЕТ — костыль в конфиг не нужен.
## Where I stopped
ТЗ (полное, под новичка, с таблицей префиксов и выделенной граблей «ведущий слэш в ключе S3») лежит в `~/projects/MoreThenCms/.agents/inbox/2026-07-03T06-40-09Z-admin.md`.
## Next action
Дождаться `MoreThenCms.FileStorage.S3.dll` от прогера → развернуть на локальный IIS (windows-recovery-host) → сквозняк: upload из админки → объект в MinIO с корректным ключом+Content-Type → фронт через imgproxy отдаёт 200 → byte-parity (ETag/MD5). Затем deploy на боевой + переключить `<fileStorageClients>` секцию, секреты прокинуть из окружения (не хардкод).
## Пайплайн
ТЗ ✅ → сборка ⏳ (прогер) → deploy-local ⏳ → сквозняк-приёмка ⏳ → deploy-remote ⏳ → close split-brain.
## Контекст
- `.wiki/concepts/snolla-admin-appdata-acl-500-after-scp-migration.md` (500 + split-brain + fix)
- `.wiki/concepts/galleries-storage-class-local-not-s3.md` (Local-класс, миграция в S3, префиксы)
- Абстракция: `MoreThenCms/FileStorage/FileStorageClient.cs` + `Azure/AzureCloudStorage.cs` (образец)
**Branch:** n/a (admin ops + external code)
<!-- created-by: vitya@.admin-exec / 2026-07-03 / trigger: user-план «сайты→snolla, админка→локальный IIS», нужен S3-провайдер -->

View File

@@ -0,0 +1,38 @@
# tandemmebel-deploy-snolla-0-42-0
## Goal
Пересобрать VDS-staging образ tandemmebel на движке `@snollajs/snolla@0.42.0` (core 0.24.0 / data 0.14.1) — суперсидит ранее принятый 0.16.2-staging (`b02ca18`). Топология = вариант A (оператор): пересборка staging ДО cutover, прод не трогаем in-place. Прод-флип остаётся тем же DNS-gated событием (reg.ru→89.253.255.94, хозяин сайта) из paused `[tandemmebel-web-vds-deploy]`. Этот таск готовит образ + перепрогоняет гейты.
## Key files
- `~/projects/tandemmebel.ru/apps/web/package.json:12` — пин `@snollajs/snolla` (сейчас 0.40.0, нужен 0.42.0)
- `~/projects/tandemmebel.ru/deploy/Dockerfile` — multi-stage node:22-slim build
- `host-stacks/vds-kzntsv/tandemmebel.compose.yml` — Portainer стек 20, source-of-truth (staging-host; боевые Host() только в cutover-комменте)
- `.wiki/concepts/labtools.pro-vds-deploy-runbook.md` — рунбук-зеркало
## Decisions log
- 2026-07-12: **In-place bump 0.42.0→0.42.1 🟢 LIVE.** Поверх cutover'а 2026-07-12 (стек 20 уже боевой, образ ed96b18). Консюмер-бамп сделан оператором сам (пин `apps/web/package.json:12` 0.42.0→0.42.1 + `yarn install` lock + commit `0cd9351` + push origin подтверждён ls-remote — тот же блокер-паттерн 0.42.0 решён без запроса dev-source). Verdaccio: snolla 0.42.1/core 0.24.1/liquid 0.10.2 published (latest). Build на VDS → `registry.kzntsv.site/tandemmebel:0cd9351` (digest f29c187f). Throwaway-staging :5020 из env живого стека, healthy. Completeness-gate С VDS: **184/184 parity** (NEW==PROD, включая 0.42.x sitemap-реструктуризацию), единственный 404 `/articles` идентичен прод-оракулу → benign. Operator-gated PUT стека 20 (env 8/8 сохранён, node put-stack.js, prune:false pullImage:true) → контейнер 0cd9351+healthy за ~8s. Live-smoke GREEN (robots/home/projects/sitemap 200, TLS-серт CN=tandemmebel.ru не дёрнут). 0.42.1 = order-tag Drop-field fix (инертен на этой теме — блог-портфолио без e-commerce каталога, как archive на 0.40→0.42). Rollback = тег ed96b18 в registry (+ b02ca18). Tandemmebel закрывает тираж 0.42.1: теперь ВСЕ 5 snolla-сайтов (labtools.ru/emspb.ru/labtools.pro/kupimknigi+tandemmebel) — kupimknigi+tandemmebel были на 0.42.0, tandemmebel подтянут до 0.42.1.
- 2026-07-04: **CLOSED 🟢.** Staging пересобран на 0.42.0 (ed96b18), оба acceptance-гейта GREEN на новом образе (не унаследованы). Caveat dev-source подтвердился эмпирически: дельта 0.40→0.42 (archive-роуты) инертна на этой теме — completeness 172/172 + gallery 12/12 без диффов. Cutover остаётся отдельным DNS-gated шагом. Follow-up (не блокер): Dockerfile печёт VERDACCIO_TOKEN в ARG/ENV → build-secret (передано dev/workshop).
- 2026-07-04: **BLOCKED на входе.** Проверил репо-провенанс: snolla-репо на 0.42.0 (`bcee2d4`, опубликовано), НО tandemmebel.ru origin/master `9fa30a7` пинит `@snollajs/snolla 0.40.0` — консюмер-бамп 0.40→0.42 не закоммичен/не запушен (ни один реф не содержит 0.42/0.24). Byte-verify «199/199 на 0.42.0» из тела таски гонялся против непушнутого локального бампа. Запросил у dev-source (victor/snolla inbox) коммит-бамп + sha. Peer-дисциплина: заявленный state таски ≠ реальность репо → репорт оператору, не проглатывать.
- 2026-07-04: Топология A ратифицирована оператором+workshop. Cutover остаётся DNS-gated (внешнее событие).
## Open questions
- [ ] Опубликован ли `@snollajs/snolla@0.42.0` в verdaccio/registry так, что `yarn install` в docker-build его резолвит? (snolla commit говорит «published» — проверить при build)
- [ ] Развести с paused `[tandemmebel-web-vds-deploy]` — пометить старый пин суперсиженным, не плодить два конкурирующих образа.
## Completed steps
- [x] Вызваны обязательные скилы (using-tasks, project-discipline, using-vds-ops).
- [x] Репо-провенанс проверен — блокер выявлен (консюмер-пин не запушен).
- [x] Запрос на пуш бампа отправлен dev-source (victor/snolla).
- [x] Dev-source запушил бамп → sha `ed96b18`, верифицирован (пин+yarn.lock=0.42.0/core0.24.0/data0.14.1), verdaccio publish подтверждён (snolla 0.42.0, core 0.24.0, data 0.14.1 present).
- [x] Собрал образ на VDS `registry.kzntsv.site/tandemmebel:ed96b18` (digest ca4da79, 583MB, BUILD_EXIT=0), запушил в registry.
- [x] Обновил compose (source-of-truth) на ed96b18, PUT стека 20 через Portainer API (env 8/8 сохранён, PullImage) → контейнер healthy, running==ed96b18.
- [x] Acceptance#1: SSR / →200, /projects →200 (real title, не 500).
- [x] Acceptance#2 completeness-DoD с VDS: sitemap 172/172 паритет (0 real-fail), gallery-grid 12/12 точный паритет, крошки+title==prod, sharp webp serve-bytes ✅.
- [x] Обновил paused [tandemmebel-web-vds-deploy] — образ к флипу = ed96b18 (суперсидит b02ca18).
- [x] Таска → 🟢 done.
## Notes
- Acceptance (из тела таски на борде): (1) pre-deploy SSR `/projects`+blog-listing → 200 не 500 (новые SELECT-колонки); (2) completeness-DoD перепрогнать на НОВОМ 0.42.0-образе (188 URL + gallery grid 15 роутов), не наследовать GREEN с 0.16.2; (3) archive-страницы НЕ блокер (projects archivesPath=""); (4) rollback наготове.
- Build-рецепт: `git -C ~/projects/tandemmebel.ru archive <sha> | ssh vitya@89.253.255.94 tar -x`; `docker build -f deploy/Dockerfile --build-arg VERDACCIO_TOKEN -t registry.kzntsv.site/tandemmebel:<sha> . && push`. Portainer PUT/POST через curl -X + node (НЕ PS Invoke-RestMethod).
- Smoke гнать С VDS (воркстейшн ловит LAN-DNS-перехват прод-доменов).
- Notify: OpeItcLoc03/workshop (оркестратор) + heads-up victor/snolla (dev-source).

View File

@@ -0,0 +1,45 @@
# kupimknigi-deploy-snolla-0-42-0
## Goal
Собрать VDS-staging образ `kupimknigi.spb.ru` на `@snollajs/snolla@0.42.0` из `victor/kupimknigi.spb.ru` (`apps/web`, HEAD **`9608ff6`**) и передеплоить/создать стек. Простой **одностраничник** — код закрыт (re-review PASS, docker-валидация GREEN локально, секреты не в git/образ). Финал тиража snolla.
## Site (факты, резолвнуты из БД прогом)
- siteId `E924A354-0377-4E1E-80C6-2EB0194AA55F` · theme `EF2C663C-8960-4D3B-83AA-5C683D47D6C0` («bootstrap», store-путь `ef2c663c89604d3b83aa5c683d47d6c0`)
- бой `https://kupimknigi.spb.ru`**БЕЗ www** (www мёртв, оператор подтвердил). Production=false в БД.
- Структура: Pages=1 (`/`, content_page), Forms=1 (`/callback-order` POST). Каталога/стора/блога/фида/редиректов нет.
## Key files
- `~/projects/kupimknigi.spb.ru/apps/web/package.json` — пин `@snollajs/snolla@0.42.0`
- `~/projects/kupimknigi.spb.ru/deploy/Dockerfile` — multi-stage node:22-slim non-root, build-arg `VERDACCIO_TOKEN`, секреты не бейкаются
- config: `default.json` gitignored; на VDS передаётся через env (`custom-environment-variables.json` маппинг) — БД `mssql.kzntsv.site/MoreThenCms`, S3/MinIO, imgproxy
## Acceptance (право-масштабировано под одностраничник — НЕ tandemmebel)
- build sha `9608ff6` на VDS → образ в `registry.kzntsv.site` → стек Portainer (env verbatim, healthy MSSQL+S3).
- Staging-smoke с VDS: `/` → 200, рендер == бой `kupimknigi.spb.ru` (H1 «Скупка старых книг в Санкт-Петербурге»), тема-ассеты 200 из MinIO, форма `/callback-order` POST жива, seoCanonical (`/callback-order/`→301). Completeness тривиален (1 URL).
- rollback наготове.
## Cutover — operator-gated (как весь тираж)
Live DNS flip **НЕ трогать** без отмашки оператора. ⚠️ kupimknigi DNS сейчас на RUVDS IIS `80.64.31.36` (майская iis-migration) — cutover-таргет/порядок разрешить с оператором отдельно, RUVDS=rollback. Этот таск = staging-образ + гейты, не флип.
## Meta
- **Weight:** needs-claude (staging-сборка; cutover=operator-gated отдельный шаг)
- **Notify:** OpeItcLoc03/workshop (оркестратор — «его сайт = workshop»)
- Deploy-source dev: `victor/kupimknigi.spb.ru`.
**Status:** done — 2026-07-05 (admin). GREEN.
**Where I stopped:** закрыто, все гейты зелёные.
**Next action:** — Cutover=operator-gated отдельный шаг.
## Completed steps (2026-07-05, admin)
- [x] Пин верифицирован: sha `9608ff6` резолвит snolla 0.42.0 (package.json + yarn.lock), Dockerfile OK, порт 5000, healthcheck=robots.txt. production.json запечён (siteId E924A354/siteUrl kupimknigi.spb.ru/DB/imgproxy/minio). Консюмер-пин на месте (не было tandemmebel-блокера).
- [x] snolla 0.42.0 в verdaccio подтверждён (ранее в сессии).
- [x] Build на VDS `registry.kzntsv.site/kupimknigi:9608ff6` (digest `ac7f846`, 583MB, BUILD_EXIT=0), push OK.
- [x] Создал НОВЫЙ Portainer-стек **Id 21 `kupimknigi`** (POST create/standalone/string, endpointId=1, env verbatim 8/8 из стека 20 — тот же snolla-тенант). Контейнер healthy сразу, running==9608ff6.
- [x] Staging-smoke с VDS GREEN: `/`→200==prod; H1 «Скупка старых книг в Санкт-Петербурге…» идентичен prod; robots.txt 200; тема-ассет toolbox.css→200 (MinIO); форма action="/callback-order" в HTML; callback-роуты паритет prod (`/callback-order/`→301, `/callback-order`→404 POST-only); байты 17717≈17672.
- [x] compose source-of-truth: `host-stacks/vds-kzntsv/kupimknigi.compose.yml` (staging-host, боевой Host только в cutover-комменте).
- [x] Таска 🟢 done, notify workshop.
## Decisions log
- 2026-07-05: **CLOSED 🟢.** Тривиальный одностраничник, деплой чистый, без блокеров. Форму не сабмитил (POST=реальное письмо клиенту) — роут зарегистрирован==prod + отрендерен, достаточно для staging. Cutover остаётся operator-gated (kupimknigi DNS на RUVDS IIS, майская миграция — таргет/порядок с оператором). Follow-up (общий тиражу): Dockerfile печёт VERDACCIO_TOKEN в ARG/ENV → build-secret (парный фикс с dev, отложен).
<!-- created-by: workshop / 2026-07-05 / dev-source victor/kupimknigi.spb.ru HEAD 9608ff6 -->

View File

@@ -0,0 +1,34 @@
# on-snolla-vds-migration
## Goal
Мигрировать посадочную `on.snolla.com` с RUVDS IIS (MoreThenCms .NET) на VDS как Node snolla-app `@snollajs/snolla` 0.42.1. Исходников сайта нет — реконструировать из боевого сайта + админки. **Спека:** [[../.wiki/concepts/snolla-local-admin-and-on-snolla-migration-design.md]] §Task B.
## Facts (из `MoreThenCms` DB)
- `SiteId=B9ECDB50-2B93-4CE5-B238-A9918B3F9CF7`, `Alias=on`, `Title="Internal Site"`, `Culture=en`, `Live=1, Production=1`.
- Контент (DB-строки + ассеты) уже на vds-kzntsv (общая `MoreThenCms` DB + MinIO) — нужно только репо шаблонов+конфига.
## Plan / phases
- [ ] **1. Реконструкция шаблонов** — через локальный админ ([[snolla-local-admin-restore]]) + боевой `https://on.snolla.com/` HTML: для каждой страницы сопоставить вёрстку с DB-контент-структурой → `layout.liquid` + page/partials. Итеративно (пиши→рендерь→сверяй с боевым).
- [ ] **2. Новый репо** `victor/on-snolla` (имя уточнить) — калька `tandemmebel.ru/apps/web` (server.js/index.js/config/views/package.json + deploy/Dockerfile). Пин `@snollajs/snolla` 0.42.1.
- [ ] **3. production.json** — siteId `B9ECDB50…`, siteUrl `https://on.snolla.com`, sequelize→`MoreThenCms` `mssql.kzntsv.site:1433`, s3→`minio.kzntsv.site`, imgproxy→`imgproxy.kzntsv.site`, sharp serve-bytes. Секреты в runtime-env стека.
- [ ] **4. Build на VDS** (обход traefik-499, build-arg VERDACCIO_TOKEN), push `registry.kzntsv.site/on-snolla:<sha>`.
- [ ] **5. Throwaway-staging `:50XX`** из env живого (или нового стека) → completeness-gate: sitemap parity vs прод-on.snolla.com (page-locs NEW==PROD, self-consistency 0 регрессий 404/5xx).
- [ ] **6. Stack** Portainer за traefik `Host(on.snolla.com)`, `mem_limit 512m`, env секреты.
- [ ] **7. Cutover** — verify авторит. NS reg.ru→89.253.255.94 → traefik Host-rule → LE-серт → live-smoke. По `tandemmebel-vds-deploy-runbook`.
## Status
⚪ ready (unblocked — [[snolla-local-admin-restore]] DONE). **Отложен в след. сессию.** Branch: master.
## Depends on / blocks
- ~~Blocked by: [[snolla-local-admin-restore]]~~ — DONE (локальный админ жив, 6 `/admin` URL).
## Decisions (locked 2026-07-20)
- **Repo:** `victor/on.snolla.com` (на git.kzntsv.site), структура-калька `victor/tandemmebel.ru`.
- **Admin-креды:** в БД `MoreThenCms.dbo.Accounts` (читать SQL-user'ом `snolla` / SA `pass mssql-vds/sa-password`). Логин-форма `/admin/account/login`.
- **Контент-инспекция:** hosts→127.0.0.1 активен (Task A), `http://on.snolla.com/` рендерит локальный IIS из той же MoreThenCms DB → контент == прод; `/admin` — контент-дерево.
## Open Q
- Сложность посадочной (объём шаблонов) — узнается при реконструкции (Title="Internal Site", culture `en` — вероятно простой лендинг).
## Rollback
Образ предыдущего тега в registry / revert DNS reg.ru→80.64.31.36 (RUVDS IIS жив пока DNS не флипнут).

View File

@@ -0,0 +1,39 @@
# snolla-local-admin-restore
## Goal
Восстановить локальный .NET-админ (catch-all IIS-сайт `snolla`) на этой машине (`windows-recovery-host`), снесённый 2026-06-08 при декоммишене. Назад — с RUVDS (текущий прод-админ с MinIO drop-in). Даёт `/admin` для всех сайтов тиража + on.snolla.com локально. **Спека:** [[../.wiki/concepts/snolla-local-admin-and-on-snolla-migration-design.md]] §Task A.
## Why
Тираж уехал на VDS (Node), админка осталась на RUVDS .NET. Нужно локально редактировать контент (особенно on.snolla.com для Task B) без зависимости от RUVDS. Временно — пока админ не мигрирован на VDS.
## Addressing (confirmed оператором)
Catch-all `*:80` + `hosts`-override → `127.0.0.1 <alias>.snolla.com`. Один AppPool `snolla`. URL = `<alias>.snolla.com/admin`:
- tandemmebel.snolla.com/labtools.snolla.com/labtoolspro.snolla.com/emspb.snolla.com/kupimknigi.snolla.com/on.snolla.com
## Plan / phases
- [x] **0. Verify source (read-only SSH RUVDS)** — plink `-hostkey` (fingerprint `SHA256:r/vSKU5WzH4B8T7RiXyXlg0D8XZ9hlBxzmdrPzuPWzE`, креды `pass show ruvds-iis/full-env`). `C:\sites\snolla\` есть, IIS site `snolla` Started, catch-all `*:80` + SNI `*.snolla.com`/real-domains. **Web.config уже → `mssql.kzntsv.site,1433;Catalog=MoreThenCms;User Id=snolla;Password=...` (тот же snolla SQL-user что stostayer.old).** MinIO drop-in применён: assets/galleries/images/scripts/stylesheets/watermarks → `FileStorage.S3.*`, endpoint `minio.kzntsv.site`, **ключи реальные** (placeholder_count=0, accessKey=20/secretKey=40). Кэши Local.
- [x] **1. Копировать `C:\sites\snolla\`** RUVDS→локально**selective tar-over-SSH ~100MB** (НЕ 8.76GB): `bin`(53MB, вкл S3 DLLs)+`Admin`(6MB)+`Areas`+`Views`+`Web.config`+`App_Data/{maxMind,searchIndexes,contentCache}`, исключая stale `App_Data/{assets(215MB),galleries(5.5GB),themes(2.8GB),uploadCache(145MB)}` + `bin.old`. Команда: `plink ... "tar -C C:/sites/snolla -cf - --exclude=App_Data/assets --exclude=App_Data/galleries --exclude=App_Data/themes --exclude=App_Data/uploadCache --exclude=bin.old --exclude=App_Data/imageCache ." | tar -C /c/sites/snolla -xf -`.
- [x] **2. Репойнт conn-string**НЕ НУЖНО, RUVDS-Web.config уже на `mssql.kzntsv.site` (cutover 2026-05). DB-креды `snolla`/`fXkH4@8O%3pc` (plaintext в Web.config, тот же user работает для MoreThenCms+stostayer catalogs). Q2 закрыта.
- [x] **3. MinIO-ключи** — уже реальные в скопированном Web.config (см. step 0). Pass-lookup не понадобился.
- [x] **4. IIS-сайт `snolla`** — elevated `scripts/local-snolla-admin-restore/setup-local-snolla-admin.ps1` (ASCII-only, PS5.1 BOM-less-safe): AppPool `snolla` (.NET v4.0, AppPoolIdentity, recycling.memory 200MB), IIS site `*:80` catch-all, ACL `IIS AppPool\snolla:(OI)(CI)(M)`.
- [x] **5. hosts-override**`127.0.0.1 tandemmebel/labtools/labtoolspro/emspb/kupimknigi/on .snolla.com` в `hosts` (marker `# snolla-local-admin`, idempotent).
- [x] **6. FW**`New-NetFirewallRule block-inbound-80-snolla-local` (Action Block, loopback не фильтруется → local-only). URL Rewrite rule НЕ ставил (скопированный inert).
- [x] **7. Smoke**`curl --noproxy '*' -H "Host: <alias>.snolla.com" http://127.0.0.1/admin/account/login`**200 для всех 6** (tandemmebel/labtools/labtoolspro/emspb/kupimknigi/on). HTML = реальная MoreThenCms-логинформа (`<title>SNOLLA</title>`, `<form action="/admin/login">`). hosts + IIS listening confirmed.
## Status
🟢 DONE (2026-07-20). Branch: master. Local .NET-админ поднят, 6 адресов `/admin` живые.
## Verify-факт
- Catch-all routing: `appSettings` = `sitePath=C:\sites\snolla\`, `primaryDomain=snolla.com`, `primaryAlias=on` (default-site = on.snolla.com "Internal Site"); siteId резолвится из Host-header (нет siteId в appSettings, в отличие от per-site stostayer.old). `customErrors mode="Off"`.
- bin/ несёт `MoreThenCms.FileStorage.S3.dll` + `AWSSDK.Core.dll` + `AWSSDK.S3.dll` (drop-in).
## Depends on / blocks
- Блокирует: [[on-snolla-vds-migration]] (нужен админ для контент-инспекции on.snolla.com).
## Open Q
- ~~MoreThenCms DB app-аккаунт~~ — ЗАКРЫТА: `snolla` SQL-user уже в Web.config (plaintext), работает для MoreThenCms catalog.
- Включать все ~60 сайтов или только тираж(5)+on.snolla.com — пока 6 в hosts; catch-all даёт все, расширить = дописать `<alias>.snolla.com` в hosts.
- **MinIO upload-acceptance** (не автотест): оператор логинится в админку, заливает тест-ассет → проверяет объект в MinIO `minio.kzntsv.site` (bucket assets). S3-провайдер был admin-self-test green на RUVDS, Web.config скопирован 1:1 → должен работать.
## Rollback
Удалить IIS-сайт `snolla` + `C:\sites\snolla\` + откатать hosts (снести marker `# snolla-local-admin`). FW-block-rule можно оставить. Прод-RUVDS не тронут.

View File

@@ -0,0 +1,31 @@
[//]: # (created 2026-08-14, TZ согласовано vitya; snolla занят задачами vitya — делать в его очередь)
# snolla-mailer-per-recipient-send
## Goal
Mailer (@snollajs/mailer / forms-api, `emailSender.js`): отправлять письмо формы **отдельным письмом на каждого получателя** (loop по FormRecipients), а не одним письмом с несколькими адресами в To.
## Почему
2026-08-14: после переезда почты форм на холодный ящик `e-16513832@yandex.ru` (app-password) Яндекс режектит **554 5.7.1 SPAM** ЛЮБОЕ письмо с **2+ получателями**; с 1 получателем — проходит (изоляционные тесты: 1→250, 2→554 стабильно; боевой заказ #2497 с 1 получателем — 0 ошибок). Старый ящик `noreply@snolla.com` был прогрет → мульти-получатели раньше проходили. В БД **11 форм с 23 админами** — их письма падают.
## Требования (код, victor/snolla)
1. Цикл по получателям, по одному письму на каждого (тот же шаблон/контент).
2. Dedupe одинаковых адресов.
3. Ошибка одного получателя не роняет остальные (per-recipient try/catch + лог).
4. Cc/Bcc/Reply-To сохранить.
5. Тест: форма с 2+ админами → по одному письму на каждого, все уходят (без 554).
## Координация
- **2026-08-14**: snolla опубликовал `@snolla/mailer@0.9.0` (bb8599d) + бампы `forms-api@0.2.0` (^0.9.0) + `web@0.44.0` (^0.9.0). **Деплой ВЫПОЛНЕН**: pilonuxt `8b7e8c4` (forms-api 0.2.0), env-сайты web@0.44.0 (`83513ef/0cd20fc/698d5f5/3f6f0c4/ccd620c/a649bcf/4aaa175`); data дедюпнут до единого 0.15.1. **Попутный фикс**: у env-сайтов в `production.json` был запечён `mailSettings.from=noreply@snolla.com` → 550 not-owned (kupimknigi подтвердил) → from заменён на e-16513832@yandex.ru во всех 7 + пересборка. **Приёмка**: pilorama98 #2498 — 3 письма (2 админа + клиент), 0 ошибок; kupimknigi #516 — 2 отдельных письма (bookvam + vitya), 0 ошибок. mailer 0.9.0 подтверждён во всех 8 контейнерах. DONE.
- После релиза (admin): деплой тиража (pilonuxt 16 + env 1723) → контрольный заказ с 2 админами → 2 письма в ящиках (vitya + info@pilorama98.ru). FormRecipients НЕ трогаем. — выполнено 2026-08-14.
- До фикса: письма админам по формам с 2+ получателями могут падать (554) — известное состояние, vitya в курсе.
## Acceptance
- [x] Форма с 2+ админами → по одному письму на каждого, все уходят (без 554/550) — pilorama98 #2498 + kupimknigi #516 подтверждены.
- [x] После деплоя контрольный заказ: vitya + менеджер оба получили письмо (messageId в логах).
- [x] 2026-08-14: DONE.

View File

@@ -0,0 +1,37 @@
[//]: # (created 2026-08-14 via user request after SMTP incident: «периодический мониторинг, что отправка с сайтов snolla работает, чтобы жила на vds»)
# snolla-smtp-send-monitor-vds
## Goal
Периодический мониторинг того, что SMTP-отправка писем форм с snolla-сайтов реально работает. **Живёт на VDS (vds-kzntsv)** — проверяет снаружи от воркстейшна, не зависит от рабочей машины.
Мотивация — инцидент 1214.08.2026 (`pilorama-forms-email-down`): Яндекс отключил протокольный доступ к `noreply@snolla.com`**2 дня тишины**: формы отвечали «успешно», заказы копились в БД, писем не было (mailer глотает ошибку `catch { console.log }`). Монитор должен ловить такой сбой в ближайший тик, а не через N дней.
## Что проверять (тик = ежедневно)
1. **SMTP auth/send-тест** на `smtp.yandex.ru:465` (SSL) с боевыми кредами snolla-почты (`e-16513832@yandex.ru` / пароль приложения, канон-стора `pass snolla-smtp/full-env`):
- минимум: `verify()`-эквивалент (auth + RCPT, без тела) — дёшево, ловит 525/535/connection;
- опционально 1×/нед: реальная тестовая отправка на `info@pilorama98.ru` + `vitya.kuznetsov@gmail.com` (доставка до ящика, не только до SMTP-сервера).
2. **Parity-чек конфига (дёшево, ловит дрейф до поломки):** SMTP_USER/SMTP_PASSWORD в env Portainer-стеков snolla-сайтов (pilonuxt/конфиг-запечка, tandemmebel 20, emspb 18, labtools 17, labtools-pro 19, kupimknigi 21, on-snolla 22, maljarka 23) == актуальным кредам канон-сторы. Расхождение → алерт (будущий сайт снова сломается молча).
## Где живёт
- **Вариант A (рекомендую):** bash/node-скрипт на VDS-хосте + cron (например `08:00 MSK`), креды в root-only файле `/root/.snolla-smtp-monitor.env` (chmod 600, зеркало `pass snolla-smtp/full-env`; в git НЕ класть), лог `/var/log/snolla-smtp-monitor.log`.
- **Вариант B:** отдельный маленький контейнер/стек через Portainer (гомогенно с тиражом, но тяжелее — нужен образ).
- **Алерт:** ntfy push (ntfy уже живёт на VDS), topic напр. `snolla-smtp`; тишина при OK (или лёгкий daily OK-пинг). При алерте — exit 1 + дубль в лог.
## Acceptance criteria
- [ ] Скрипт на VDS выполняет SMTP auth-тест с боевыми кредами; 525/535/connection-fail → ntfy-алерт + exit 1.
- [ ] Cron ежедневный (время после 08:00 MSK), логи пишутся.
- [ ] Проверено негативом: временно сломать пароль в env-файле → алерт приходит (за < 1 мин при ручном прогоне).
- [ ] Parity-проверка Portainer env vs канон реализована (или явно descoped).
- [ ] Ранбук/README в `.admin` (`host-stacks/vds-kzntsv/` или `scripts/`), креды — только через pass/root-only файл.
## Key files / refs
- Креды: `pass snolla-smtp/full-env` (после ротации 2026-08-14: `e-16513832@yandex.ru` / app-password).
- Инцидент: `.tasks/pilorama-forms-email-down.md` (pilorama98) — почему монитор нужен.
- Portainer API: `pass vds-kzntsv/full-env` (`PORTAINER_URL`/JWT-аут), `.wiki/concepts/portainer-stack-management-vds.md`.
- ntfy: контейнер на VDS (стек из host-stacks).

View File

@@ -0,0 +1,61 @@
# llm-router-failover-proxy
## Goal
LLM-роутер / failover-прокси для pi (кодинг-агент на Windows-воркстейшне vitya). Группирует все deepseek-v4-flash модели из разных роутеров в один алиас (`deepseek-flash`), отдельно deepseek-v4-pro (`deepseek-pro`). Приоритет: **free-модели первыми**, затем по приоритету провайдеров, официальный DeepSeek API (платный) — последним fallback'ом. При ошибке апстрима (429/5xx/таймаут/сеть) — автоматический переход к следующему.
Заказчик: vitya (через snolla-сессию). **Решение, куда деплоить — за админом** (см. «Деплой»).
## Контекст
Потребитель один — pi на машине vitya (`~/.pi/`). Сейчас модели прописаны вручную в `~/.pi/agent/models.json` (4 провайдера, ключи в `~/.pi/agent/auth.json`):
| Провайдер | baseUrl | deepseek-v4-flash | deepseek-v4-pro |
|---|---|---|---|
| teamorouter | https://api.teamorouter.com/v1 | `deepseek-v4-flash-free` (free) | `deepseek-v4-pro-free` (free) |
| orcarouter | https://api.orcarouter.ai/v1 | `deepseek/deepseek-v4-flash-free` (free) | `deepseek/deepseek-v4-pro-free` (free) |
| anymodel | https://anymodel.org/v1 | `am/deepseek-v4-flash` (free) | `am/deepseek-v4-pro` (free) |
| routerai | https://routerai.ru/api/v1 | — | — |
| deepseek (официальный) | https://api.deepseek.com | `deepseek-chat` (платный) | `deepseek-reasoner` (платный) |
- Ключи всех апстримов уже лежат в `~/.pi/agent/auth.json` (формат: `{ "<provider>": { "type": "api_key", "key": "..." } }`). Конфиг прокси может ссылаться на них или держать свои копии — на усмотрение админа (ключи не публиковать, gitignore).
- Все free-модели — это deepseek-v4-flash/pro у разных посредников. Официальный DeepSeek API платный — **последний** в цепочке (fallback, когда все free лежат).
- Ранее обсуждено (рекомендация, НЕ приказ): деплой локально на Windows как winsw-сервис (паттерн agents-task-runner уже обкатан), Node-прокси без docker/WSL2. **Решение за админом.**
## Требования
1. **OpenAI-совместимый эндпоинт** (`/v1/chat/completions`, openai-completions API). pi подключается одной записью в `~/.pi/agent/models.json` (провайдер + алиасы).
2. **Алиасы моделей**: `deepseek-flash` → пул flash-апстримов, `deepseek-pro` → пул pro-апстримов.
3. **Приоритет выбора**: free-апстримы (teamorouter → orcarouter → anymodel, порядок конфига) → платный официальный DeepSeek → (остальные провайдеры, если появятся).
4. **Failover**: на 429 / 5xx / таймаут / сетевую ошибку — retry к следующему апстриму в порядке приоритета. Желательно: cooldown для упавших апстримов (не долбить мёртвый), опционально circuit-breaker.
5. **Прозрачный passthrough**: SSE-стриминг, tool-calls (pi использует активно, много раундов), thinking-блоки deepseek (reasoning) — всё должно доезжать без искажений. Модель в ответе — какую реально использовал.
6. **Логика по умолчанию**: если все free живы — платный апстрим не вызывается (экономия).
7. **Конфиг** — файл (YAML/JSON): алиас → упорядоченный список апстримов (baseUrl, key-ref, model-id, free/paid флаг, таймауты).
8. **Учёт**: лог, какой апстрим обслужил запрос + usage. Cost-трекинг не обязателен (модели free), но знать, что сработал платный fallback — обязательно.
9. **Тесты**: TDD (см. ниже).
## Acceptance
- [ ] pi подключается к прокси одной записью в `~/.pi/agent/models.json`; `/model` (или `pi --list-models`) показывает `deepseek-flash` / `deepseek-pro`.
- [ ] Стриминг работает: ответ идёт потоком (не цельным blob).
- [ ] Tool-calls работают: несколько последовательных инструмент-раундов (типа как pi вызывает tools).
- [ ] Failover доказан тестом: верхний апстрим недоступен → запрос обслуживает следующий; при живых free — платный НЕ вызывается.
- [ ] Thinking/reasoning-контент deepseek не ломается (хотя бы smoke: ответ не пустой, без мусора).
- [ ] Инструкция для pi-интеграции (что писать в models.json) + как запускать/останавливать прокси (сервис/скрипт).
- [ ] Деплой выполнен (локально или VDS — решение админа) и живой smoke пройден.
## Деплой (решение за админом)
Рекомендация заказчика: **локально** на Windows (Node-процесс, winsw-сервис — паттерн agents-task-runner; ключи не уходят на сервер; потребитель один — pi на этой машине; docker/WSL2 на Windows — боль). Если админ видит причину иначе (VDS, доступ с других устройств, Portainer-стек) — обосновать и сделать. Ключи апстримов на VDS — только если деплой туда, тогда по правилам админа (pass / env, не в git).
## Обязательные скилы — вызвать до начала работы
- invoke `tdd-criteria` — до написания кода
- invoke `using-tasks` — управление статусом задачи
- invoke `project-discipline` — коммиты/пуши
- invoke `using-wiki` после закрытия — заингесть `.wiki/concepts/llm-router-failover-proxy.md` (архитектура, решения, runbook)
**TDD:** да — failover/приоритет/стриминг это чистые юнит-кейсы; тест-харнесс обязателен (мок-апстримы).
**Разрешения:** интерны: да | автопуш: да
**Weight:** needs-claude
**Notify:** victor/snolla

View File

@@ -0,0 +1,76 @@
# sched-pipelines-local-stack
Локальный стенд: sched daemon + HTTP-воркеры (yandex-market-partner-api-client, ozon-seller-api-client)
+ CDP-browser + локальный ntfy + alert-bridge (sched webhook → ntfy/email Unisender Go).
Потом миграция на VDS.
## Статус (2026-08-20 вечер)
**Фикс sched принят (0.6.0, проверено живьём):**
- applyTask переносит timeoutMs при upsert существующих задач ✓ (был null → стал -1)
- дефолт absent = -1 ✓ — timeoutMs убран из tasks.json, live-sync подхватил, таски остались -1 (без рестарта)
**Сделано в этой сессии:**
- schedd обновлён 0.4.2 → **0.5.0** (core 0.45.0): envelope-дыра → громкий broken-worker, относительный statusUrl, poll-ретраи 2×500мс, run-deadline контракт (task-level timeoutMs).
- tasks.json: `"timeoutMs": -1` на обе пайплайн-таски (рекомендация sched). ⚠️ **НЕ применился**баг sched: `applyTask` при upsert существующей задачи не переносит `timeoutMs` (поле не в списке merged). Письмо sched 19:30Z + требование vitya: `-1` = дефолт, можно не указывать (absent должен вести себя как -1, не legacy-25мин).
- **Одновременный dry-run обеих тасок через sched — оба зелёные**: ozon 8/8 стадий succeeded, yandex 11/11 succeeded (outcome published, notify sent). Конкурентность работает.
- Воркеры: ozon пересобран с их фиксами (ТЗ №1-10, envelope реализован), ym свежий (JSDoc-фикс в образе).
- publish-мокабельность: ТЗ обеим командам (`publish-mockable` на их досках + письма): ozon — data.registry/.remoteBase/.githubApiBase (у них хардкод); ym — только data.githubApiBase (REST), registry+remoteBase уже есть.
- docker-compose: ozon bind-mount на хост (`./data-ozon` → /app/data + state.json → /app/state.json) — раньше state/output жили в слое образа и терялись.
- Находка: у ozon state.json/output в слое образа (run.js хардкодит ROOT), /app/data volume пуст — это их недоработка, state персистится только mount'ом. У ym state в bind (data-ym).
**Мок-publish на Gitea (решение vitya):**
- мок-npm = наш verdaccio (verdaccio.kzntsv.site), мок-GitHub = наш Gitea (git.kzntsv.site, орга `apilki` создана, API v1, push через x-access-token работает — проверено живьём).
- Репо руками НЕ создаю — воркеры сами (create-if-missing), это тестируемый сценарий.
- ТЗ обеим командам (`gitea-mock-publish` на досках): ozon — заменить `gh repo create` CLI на REST POST /orgs/{owner}/repos + remoteBase URL-join; yandex — только remoteBase URL-join (REST уже Gitea-совместим).
**Открытые вопросы (к sched):**
1. ✅ applyTask timeoutMs + absent=-1 — ЗАКРЫТО (0.6.0, проверено живьём).
2. Устаревшая таска `pipeline` (disabled) осталась в БД sched — sync не удаляет, контракт такой (не трогаем).
**Поднято и проверено:**
- schedd 0.4.2 (published, verdaccio) — healthy; admin API 127.0.0.1:18080 (SCHED_ADMIN_KEY из pass)
- tasks.json: ozon `0 2 * * *`, yandex `30 2 * * *`, alerts webhook (HMAC) → alert-bridge
- ym-client-builder: envelope ✓ (ping → succeeded), auth ✓ (401 на wrong key)
- browser-cdp: Chrome 131 + CDP :9222 ✓
- ntfy локальный :8096 ✓; alert-bridge HMAC ✓ + доставка в топик sched-alerts ✓
- email: unisender go2 код 229 — нужен sender-domain в аккаунте (вопрос юзеру)
**Ozon-воркер ОСТАНОВЛЕН** (их баги, ждём фиксы — ТЗ в их инбоксе):
1. envelope-контракт не реализован (всегда 200 {runId,verdict} → sched видит успех)
2. нет notify на sched-пути
3. нет publish-стадии на sched-пути (publish только в крон-пути, крон выключен)
4. email на старый домен go.unisender.ru + массив to + form → go2 JSON
5. `job.getTask()` нет в node-cron v4 → restart-loop
6. Dockerfile `COPY ... 2>/dev/null || true` — не билдится
7. lock пинит yaml на verdaccio → E401 в чистом контейнере
**Yandex (2 дефекта, ТЗ отправлено):**
1. email-эндпоинт go2.unisender.ru/api/v1/sendEmail → 404; правильный: /ru/transactional/api/v1/email/send.json (JSON body)
2. ntfy dead-домен + email.to плейсхолдер в deploy/sched-task.json
**sched (письмо «впрягись»):**
1. envelope-mode + non-envelope 2xx = тихий succeeded (маскирует фейлы) — позиция/фича
2. канон envelope-контракта для ozon-команды
3. missed-slot при ежедневных 2:00/2:30 — grace ок?
4. VDS: daemon без docker-runner — docker-сокет не нужен?
## Секреты (pass)
`sched-pipelines/local/*`: sched-api-key (общий sched→воркеры), admin-key, webhook-secret, unisender-go-api-key.
В стенде через `.env` (gitignored) → `render-tasks.cjs``tasks.generated.json`.
## Команды
```bash
cd ~/projects/.admin/host-stacks/local/sched-pipelines
node render-tasks.cjs && docker compose up -d --build
docker compose logs -f schedd
```
## Проверки GitHub-токена
`OpeItcLoc03` (gh auth), orgs: schedjs, snollacms, apitano, apilki (role **admin**).
Репо в оргах создаёт ✓ (тест `apilki/token-check-*` create + delete, delete_repo scope есть).
Нужно для create-if-missing: apilki/ozon-seller-typescript, apilki/ozon-seller-postman, apilki/yandex-market-postman (ещё не существуют).